From 38de3de0a32ae30b8ed526240a4d8b92eeb24c45 Mon Sep 17 00:00:00 2001 From: leefer Date: Fri, 31 Jul 2026 11:55:27 +0800 Subject: [PATCH] migration: preserve review journal alerts and assistant slice --- app/assistant_agent.py | 92 +---- app/backend/application.py | 298 +------------- app/backend/features/alerts/__init__.py | 19 +- app/backend/features/alerts/facade.py | 30 ++ app/backend/features/alerts/http.py | 16 + app/backend/features/alerts/repository.py | 110 +++++ app/backend/features/review/__init__.py | 24 +- app/backend/features/review/agent.py | 91 ++++ app/backend/features/review/http.py | 97 +++++ app/backend/features/review/repository.py | 281 +++++++++++++ app/backend/features/review/service.py | 188 +++++++++ app/database.py | 387 +----------------- .../test_preservation_slice_review_alerts.py | 211 ++++++++++ docs/migration/evidence/slice-09/README.md | 60 +++ .../migration/evidence/slice-09/api-diff.json | 60 +++ .../evidence/slice-09/api-requests.json | 32 ++ .../evidence/slice-09/database-diff.json | 75 ++++ .../review-workspace-night-1920x1080.png | Bin 0 -> 100490 bytes docs/migration/保真迁移状态.json | 10 +- docs/migration/保真迁移账本.md | 15 +- 20 files changed, 1323 insertions(+), 773 deletions(-) create mode 100644 app/backend/features/alerts/facade.py create mode 100644 app/backend/features/alerts/http.py create mode 100644 app/backend/features/alerts/repository.py create mode 100644 app/backend/features/review/agent.py create mode 100644 app/backend/features/review/http.py create mode 100644 app/backend/features/review/repository.py create mode 100644 app/backend/features/review/service.py create mode 100644 app/tests/test_preservation_slice_review_alerts.py create mode 100644 docs/migration/evidence/slice-09/README.md create mode 100644 docs/migration/evidence/slice-09/api-diff.json create mode 100644 docs/migration/evidence/slice-09/api-requests.json create mode 100644 docs/migration/evidence/slice-09/database-diff.json create mode 100644 docs/migration/evidence/slice-09/review-workspace-night-1920x1080.png diff --git a/app/assistant_agent.py b/app/assistant_agent.py index 94396ac..ba1b10f 100644 --- a/app/assistant_agent.py +++ b/app/assistant_agent.py @@ -1,91 +1,7 @@ -from __future__ import annotations +"""Compatibility alias for the canonical review-assistant implementation.""" -import json -import urllib.error -import urllib.request -from collections.abc import Iterator -from typing import Any +import sys -from llm_stream import OpenAIStreamAccumulator +from backend.features.review import agent as _implementation - -class ReviewAssistantError(RuntimeError): - pass - - -def stream_review_assistant( - context: dict[str, Any], - question: str, - history: list[dict[str, str]], - api_key: str, - base_url: str, - model: str, - timeout: int = 120, -) -> Iterator[str]: - if not api_key or not model: - raise ReviewAssistantError("智能解读服务尚未配置。") - messages = [{"role": "system", "content": _system_prompt(context)}] - messages.extend(history[-12:]) - messages.append({"role": "user", "content": question}) - request = urllib.request.Request( - f"{base_url.rstrip('/')}/chat/completions", - data=json.dumps( - {"model": model, "messages": messages, "stream": True}, ensure_ascii=False - ).encode("utf-8"), - headers={ - "Content-Type": "application/json", - "Authorization": f"Bearer {api_key}", - "User-Agent": "XiaobaiReviewWeb/1.0", - "Accept": "text/event-stream", - }, - method="POST", - ) - try: - with urllib.request.urlopen(request, timeout=timeout) as response: - yielded = False - accumulator = OpenAIStreamAccumulator() - for raw_line in response: - line = raw_line.decode("utf-8", errors="replace").strip() - if not line or line.startswith(":"): - continue - if line.startswith("data:"): - line = line[5:].strip() - if line == "[DONE]": - break - try: - payload = json.loads(line) - except json.JSONDecodeError: - continue - choices = payload.get("choices") or [] - if not choices: - continue - choice = choices[0] or {} - content = accumulator.feed(choice) - if content: - yielded = True - yield str(content) - if not yielded: - raise ReviewAssistantError("智能解读未返回有效内容。") - except urllib.error.HTTPError as exc: - raise ReviewAssistantError(f"智能解读服务暂不可用({exc.code})。") from exc - except (urllib.error.URLError, TimeoutError, OSError) as exc: - raise ReviewAssistantError("智能解读连接中断,请稍后重试。") from exc - - -def _system_prompt(context: dict[str, Any]) -> str: - context_json = json.dumps(context, ensure_ascii=False, separators=(",", ":")) - return f""" -你是“小白复盘”的统一复盘助手。你负责把网页中已经存在的市场统计、策略跟踪、提醒、复盘笔记和手工交易日志连接起来,帮助用户复盘和形成下一步观察计划。 - -最高优先级规则: -1. 只能使用下方“网页复盘数据”,数据缺失就明确说明,不得补造行情、交易或胜率。 -2. 不自动下单,不声称已执行任何操作,不修改策略、提醒、笔记或交易日志。 -3. 不承诺收益,不给无条件买卖指令。建议必须写成条件、失效条件和风险边界。 -4. 区分市场事实、用户记录和你的推断。引用数字时写明数据日期。 -5. 优先结合用户自己的策略跟踪与交易日志寻找可验证的重复模式;样本不足时明确标注。 -6. 使用中文,先直接回答,再给数据依据和下一步观察。避免空泛口号,不展示模型、接口或内部工程信息。 -7. 控制在 800 个中文字符以内,除非用户明确要求展开。 - -网页复盘数据: -{context_json} -""".strip() +sys.modules[__name__] = _implementation diff --git a/app/backend/application.py b/app/backend/application.py index cb4cf6c..e4e6b91 100644 --- a/app/backend/application.py +++ b/app/backend/application.py @@ -11,7 +11,6 @@ from http.server import BaseHTTPRequestHandler from typing import Any from urllib.parse import parse_qs, unquote, urlparse -from assistant_agent import ReviewAssistantError, stream_review_assistant from api_access import ROUTES from backend.bootstrap.container import build_application_container from backend.bootstrap.settings import load_runtime_settings @@ -21,14 +20,14 @@ from backend.llm.http import LLMHttpMixin from backend.llm.service import LLMServiceMixin from backend.features.market import ChartDataError, MarketServiceMixin from backend.features.heaven import HeavenHttpMixin, HeavenServiceMixin, build_personal_field +from backend.features.alerts import AlertHttpMixin, AlertServiceMixin +from backend.features.review import ReviewHttpMixin, ReviewServiceMixin from backend.bootstrap.config import ( DATA_DIR, MENTOR_SKILLS_DIR, PRIVATE_MENTOR_SKILLS_DIR, TOKEN_PATTERN, normalize_date, - tushare_code, - validate_stock_code, validate_text, ) from database import ReviewDatabase @@ -79,6 +78,8 @@ class DashboardService( ScreenerServiceMixin, MentorServiceMixin, HeavenServiceMixin, + AlertServiceMixin, + ReviewServiceMixin, LLMServiceMixin, ): def __init__(self) -> None: @@ -501,209 +502,6 @@ class DashboardService( return "" - def alert_center(self, status: str = "all", as_of: str = "") -> dict[str, Any]: - tracking = self.strategy_tracking.list_tracking(self.current_user_id, 12) - self.alert_service.sync_strategy_tracking(self.current_user_id, tracking) - return self.alert_service.list_alerts( - self.current_user_id, status, as_of - ) - - def create_alert(self, payload: dict[str, Any]) -> dict[str, Any]: - alert_id = self.alert_service.create_manual(self.current_user_id, payload) - return {"id": alert_id, **self.alert_center()} - - def mark_alert_read(self, alert_id: int) -> dict[str, Any]: - self.alert_service.mark_read(self.current_user_id, alert_id) - return self.alert_center() - - def mark_all_alerts_read(self, as_of: str = "") -> dict[str, Any]: - compact_date = self.alert_service.calendar_date(as_of or date.today().isoformat()) - self.alert_service.mark_all_read(self.current_user_id, compact_date) - return self.alert_center(as_of=compact_date) - - def delete_alert(self, alert_id: int) -> dict[str, Any]: - deleted = self.alert_service.delete(self.current_user_id, alert_id) - return {"deleted": deleted, **self.alert_center()} - - def trade_entries( - self, start_date: str = "", end_date: str = "", code: str = "" - ) -> dict[str, Any]: - return self.trade_journal.list_entries( - self.current_user_id, start_date, end_date, code - ) - - def review_watchlist(self, trade_date: str) -> dict[str, Any]: - normalized_date = normalize_date(trade_date) - items = self.database.list_watchlist(self.current_user_id) - if not items: - return {"items": [], "trade_date": normalized_date} - - resolved_date = normalized_date - if self.configured: - try: - client = self._tushare_client() - resolved_date, _ = client.resolve_trade_context(normalized_date) - history = self.database.watchlist_price_history( - [str(item["code"]) for item in items], resolved_date - ) - missing_codes = [ - str(item["code"]) for item in items - if len(history.get(str(item["code"])) or []) < 6 - ] - start_date = ( - datetime.strptime(resolved_date, "%Y%m%d") - timedelta(days=24) - ).strftime("%Y%m%d") - for code in missing_codes: - rows = client.query( - "daily", - { - "ts_code": tushare_code(code), - "start_date": start_date, - "end_date": resolved_date, - }, - "ts_code,trade_date,open,high,low,close,pct_chg,vol,amount", - ) - if rows: - self.database.upsert_daily_bars(rows) - if missing_codes: - history = self.database.watchlist_price_history( - [str(item["code"]) for item in items], resolved_date - ) - except (TushareError, ValueError): - history = self.database.watchlist_price_history( - [str(item["code"]) for item in items], resolved_date - ) - else: - history = self.database.watchlist_price_history( - [str(item["code"]) for item in items], resolved_date - ) - - auction_scores: dict[str, Any] = {} - try: - auction = self.auction_center(normalized_date, False) - auction_scores = { - str(row.get("code") or ""): row.get("attention_score") - for row in (auction.get("watchlist_rows") or []) - if row.get("available", True) - } - except (TushareError, ValueError): - pass - - enriched = [] - for item in items: - code = str(item.get("code") or "") - bars = history.get(code) or [] - latest = bars[-1] if bars else {} - close = float(latest.get("close") or 0) - base_close = float(bars[-6].get("close") or 0) if len(bars) >= 6 else 0 - enriched.append( - { - **item, - "change": ( - round(float(latest.get("pct_chg") or 0), 2) if latest else None - ), - "return_5d": ( - round((close / base_close - 1) * 100, 2) - if close > 0 and base_close > 0 else None - ), - "attention_score": auction_scores.get(code), - "market_date": str(latest.get("trade_date") or ""), - } - ) - return {"items": enriched, "trade_date": resolved_date} - - def save_trade_entry(self, payload: dict[str, Any]) -> dict[str, Any]: - trade_id = self.trade_journal.save(self.current_user_id, payload) - return {"id": trade_id, **self.trade_entries()} - - def delete_trade_entry(self, trade_id: int) -> dict[str, Any]: - deleted = self.trade_journal.delete(self.current_user_id, trade_id) - return {"deleted": deleted, **self.trade_entries()} - - def assistant_messages(self) -> list[dict[str, Any]]: - return self.database.list_assistant_messages(self.current_user_id) - - def clear_assistant_messages(self) -> int: - return self.database.delete_assistant_messages(self.current_user_id) - - def assistant_stream(self, payload: dict[str, Any]): - question = validate_text(payload.get("question"), "问题", 2000, required=True) - trade_date = normalize_date( - str(payload.get("trade_date") or date.today().isoformat()) - ) - context = self._assistant_context(trade_date) - history = [ - {"role": item["role"], "content": str(item["content"])[:4000]} - for item in self.assistant_messages()[-12:] - if item.get("role") in {"user", "assistant"} - ] - def generate(): - answer_parts: list[str] = [] - events = self.llm_gateway.stream( - "assistant", - "review-assistant-v1", - lambda profile: stream_review_assistant( - context, - question, - history, - profile.api_key, - profile.base_url, - profile.model, - ), - (ReviewAssistantError,), - ) - for event in events: - if event.kind == "delta": - chunk = str(event.value or "") - answer_parts.append(chunk) - yield chunk - elif event.kind == "complete": - self.database.save_assistant_exchange( - self.current_user_id, - question, - "".join(answer_parts).strip(), - trade_date, - ) - - return generate() - - def _assistant_context(self, trade_date: str) -> dict[str, Any]: - dashboard = self.get_dashboard(trade_date) - actual_date = normalize_date( - str((dashboard.get("meta") or {}).get("trade_date") or trade_date) - ) - sentiment = self.sentiment_history(actual_date, 10) - tracking = self.strategy_tracking.list_tracking(self.current_user_id, 5) - alerts = self.alert_service.list_alerts( - self.current_user_id, "all", date.today().isoformat() - ) - trades = self.trade_journal.list_entries( - self.current_user_id, end_date=actual_date - ) - return { - "data_date": actual_date, - "market": { - "overview": dashboard.get("overview") or {}, - "top_sectors": (dashboard.get("sectors") or [])[:8], - "limit_performance": dashboard.get("limit_performance") or {}, - "sentiment_history": (sentiment.get("rows") or [])[-10:], - }, - "personal": { - "watchlist": self.database.list_watchlist(self.current_user_id)[:30], - "review_notes": self.database.list_notes( - self.current_user_id, scope="daily" - )[:10], - "strategy_tracking": { - "summary": tracking.get("summary") or {}, - "batches": (tracking.get("batches") or [])[:5], - }, - "alerts": (alerts.get("items") or [])[:20], - "trade_summary": trades.get("summary") or {}, - "trade_entries": (trades.get("items") or [])[:30], - }, - } - - SERVICE = DashboardService() @@ -714,6 +512,8 @@ class RequestHandler( SystemHttpMixin, MentorHttpMixin, HeavenHttpMixin, + AlertHttpMixin, + ReviewHttpMixin, LLMHttpMixin, HttpTransportMixin, BaseHTTPRequestHandler, @@ -1256,43 +1056,7 @@ class RequestHandler( return self.send_json({"error": "Not found"}, HTTPStatus.NOT_FOUND) - def save_alert(self) -> None: - try: - body = self.read_json_body() - self.send_json({"ok": True, **SERVICE.create_alert(body)}, HTTPStatus.CREATED) - except (ValueError, json.JSONDecodeError) as exc: - self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) - def save_trade_entry(self) -> None: - try: - body = self.read_json_body() - self.send_json({"ok": True, **SERVICE.save_trade_entry(body)}, HTTPStatus.CREATED) - except (ValueError, json.JSONDecodeError) as exc: - self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) - - def stream_assistant_chat(self) -> None: - try: - body = self.read_json_body() - stream = SERVICE.assistant_stream(body) - except (ValueError, json.JSONDecodeError) as exc: - self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) - return - self.send_response(HTTPStatus.OK) - self.send_header("Content-Type", "application/x-ndjson; charset=utf-8") - self.send_header("Cache-Control", "no-cache, no-transform") - self.send_header("X-Accel-Buffering", "no") - self.send_header("Connection", "close") - self.end_headers() - try: - for chunk in stream: - self._write_stream_event({"type": "delta", "content": chunk}) - self._write_stream_event({"type": "done"}) - except (ValueError, ReviewAssistantError) as exc: - self._write_stream_event({"type": "error", "error": str(exc)}) - except (BrokenPipeError, ConnectionResetError): - pass - finally: - self.close_connection = True def _write_stream_event(self, payload: dict[str, Any]) -> None: self.wfile.write( @@ -1301,56 +1065,6 @@ class RequestHandler( self.wfile.flush() - def save_watchlist(self) -> None: - try: - body = self.read_json_body() - code = validate_stock_code(str(body.get("code", ""))) - name = validate_text(body.get("name"), "股票名称", 30, required=True) - sector = validate_text(body.get("sector"), "所属板块", 50) - color = str(body.get("color") or "red") - if color not in {"red", "blue", "green", "amber"}: - raise ValueError("标记颜色不支持。") - remark = validate_text(body.get("remark"), "跟踪备注", 240) - SERVICE.database.save_watchlist( - SERVICE.current_user_id, code, name, sector, color, remark - ) - self.send_json( - { - "ok": True, - "items": SERVICE.database.list_watchlist(SERVICE.current_user_id), - } - ) - except (ValueError, json.JSONDecodeError) as exc: - self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) - - def save_note(self) -> None: - try: - body = self.read_json_body() - code = str(body.get("code") or "").strip() - if code: - code = validate_stock_code(code) - stock_name = validate_text(body.get("stock_name"), "股票名称", 30) - trade_date = normalize_date(str(body.get("trade_date") or date.today().isoformat())) - summary = validate_text(body.get("summary"), "盘面摘要", 500) - content = validate_text(body.get("content"), "复盘内容", 5000) - plan = validate_text(body.get("plan"), "明日计划", 2000) - if not summary and not content and not plan: - raise ValueError("每日复盘内容不能全部为空。") - raw_id = body.get("id") - note_id = int(raw_id) if raw_id else None - saved_id = SERVICE.database.save_note( - SERVICE.current_user_id, - code, - stock_name, - trade_date, - content, - plan, - note_id, - summary=summary, - ) - self.send_json({"ok": True, "id": saved_id}) - except (ValueError, TypeError, json.JSONDecodeError) as exc: - self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) def save_reason(self) -> None: try: diff --git a/app/backend/features/alerts/__init__.py b/app/backend/features/alerts/__init__.py index f2ba0fe..3d90e6f 100644 --- a/app/backend/features/alerts/__init__.py +++ b/app/backend/features/alerts/__init__.py @@ -1,3 +1,18 @@ -from .service import AlertService +from .facade import AlertServiceMixin +from .http import AlertHttpMixin +from .repository import AlertRepositoryMixin -__all__ = ["AlertService"] +__all__ = [ + "AlertHttpMixin", + "AlertRepositoryMixin", + "AlertService", + "AlertServiceMixin", +] + + +def __getattr__(name: str): + if name == "AlertService": + from .service import AlertService + + return AlertService + raise AttributeError(name) diff --git a/app/backend/features/alerts/facade.py b/app/backend/features/alerts/facade.py new file mode 100644 index 0000000..ae225f8 --- /dev/null +++ b/app/backend/features/alerts/facade.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from datetime import date +from typing import Any + + +class AlertServiceMixin: + def alert_center(self, status: str = "all", as_of: str = "") -> dict[str, Any]: + tracking = self.strategy_tracking.list_tracking(self.current_user_id, 12) + self.alert_service.sync_strategy_tracking(self.current_user_id, tracking) + return self.alert_service.list_alerts( + self.current_user_id, status, as_of + ) + + def create_alert(self, payload: dict[str, Any]) -> dict[str, Any]: + alert_id = self.alert_service.create_manual(self.current_user_id, payload) + return {"id": alert_id, **self.alert_center()} + + def mark_alert_read(self, alert_id: int) -> dict[str, Any]: + self.alert_service.mark_read(self.current_user_id, alert_id) + return self.alert_center() + + def mark_all_alerts_read(self, as_of: str = "") -> dict[str, Any]: + compact_date = self.alert_service.calendar_date(as_of or date.today().isoformat()) + self.alert_service.mark_all_read(self.current_user_id, compact_date) + return self.alert_center(as_of=compact_date) + + def delete_alert(self, alert_id: int) -> dict[str, Any]: + deleted = self.alert_service.delete(self.current_user_id, alert_id) + return {"deleted": deleted, **self.alert_center()} diff --git a/app/backend/features/alerts/http.py b/app/backend/features/alerts/http.py new file mode 100644 index 0000000..61c544a --- /dev/null +++ b/app/backend/features/alerts/http.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +import json +from http import HTTPStatus + + +class AlertHttpMixin: + def save_alert(self) -> None: + try: + body = self.read_json_body() + self.send_json( + {"ok": True, **self.application_service.create_alert(body)}, + HTTPStatus.CREATED, + ) + except (ValueError, json.JSONDecodeError) as exc: + self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) diff --git a/app/backend/features/alerts/repository.py b/app/backend/features/alerts/repository.py new file mode 100644 index 0000000..2caec3e --- /dev/null +++ b/app/backend/features/alerts/repository.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + + +class AlertRepositoryMixin: + def save_alert( + self, + user_id: int, + kind: str, + title: str, + content: str, + available_date: str, + code: str, + dedupe_key: str, + ) -> int: + now = datetime.now().astimezone().isoformat(timespec="seconds") + with self.connect() as connection: + connection.execute( + """ + INSERT INTO alerts + (user_id, kind, title, content, available_date, code, dedupe_key, + is_read, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?) + ON CONFLICT(user_id, dedupe_key) DO UPDATE SET + title=excluded.title, content=excluded.content, + available_date=excluded.available_date, updated_at=excluded.updated_at + """, + ( + int(user_id), kind, title, content, available_date, code, + dedupe_key, now, now, + ), + ) + row = connection.execute( + "SELECT id FROM alerts WHERE user_id = ? AND dedupe_key = ?", + (int(user_id), dedupe_key), + ).fetchone() + return int(row["id"]) + + def list_alerts( + self, user_id: int, as_of: str, unread_only: bool = False, limit: int = 100 + ) -> list[dict[str, Any]]: + with self.connect() as connection: + if unread_only: + rows = connection.execute( + """ + SELECT id, kind, title, content, available_date, code, is_read, + created_at, updated_at, read_at + FROM alerts + WHERE user_id = ? AND available_date <= ? AND is_read = 0 + ORDER BY available_date DESC, id DESC LIMIT ? + """, + (int(user_id), as_of, max(1, min(300, int(limit)))), + ).fetchall() + else: + rows = connection.execute( + """ + SELECT id, kind, title, content, available_date, code, is_read, + created_at, updated_at, read_at + FROM alerts WHERE user_id = ? + ORDER BY CASE WHEN available_date > ? THEN 0 ELSE 1 END, + is_read, available_date, id DESC LIMIT ? + """, + (int(user_id), as_of, max(1, min(300, int(limit)))), + ).fetchall() + return [{**dict(row), "is_read": bool(row["is_read"])} for row in rows] + + def count_unread_alerts(self, user_id: int, as_of: str) -> int: + with self.connect() as connection: + row = connection.execute( + """ + SELECT COUNT(*) AS total FROM alerts + WHERE user_id = ? AND available_date <= ? AND is_read = 0 + """, + (int(user_id), as_of), + ).fetchone() + return int(row["total"] if row else 0) + + def mark_alert_read(self, user_id: int, alert_id: int) -> bool: + now = datetime.now().astimezone().isoformat(timespec="seconds") + with self.connect() as connection: + cursor = connection.execute( + """ + UPDATE alerts SET is_read = 1, read_at = ?, updated_at = ? + WHERE id = ? AND user_id = ? + """, + (now, now, int(alert_id), int(user_id)), + ) + return cursor.rowcount > 0 + + def mark_all_alerts_read(self, user_id: int, as_of: str) -> int: + now = datetime.now().astimezone().isoformat(timespec="seconds") + with self.connect() as connection: + cursor = connection.execute( + """ + UPDATE alerts SET is_read = 1, read_at = ?, updated_at = ? + WHERE user_id = ? AND available_date <= ? AND is_read = 0 + """, + (now, now, int(user_id), as_of), + ) + return int(cursor.rowcount) + + def delete_alert(self, user_id: int, alert_id: int) -> bool: + with self.connect() as connection: + cursor = connection.execute( + "DELETE FROM alerts WHERE id = ? AND user_id = ?", + (int(alert_id), int(user_id)), + ) + return cursor.rowcount > 0 diff --git a/app/backend/features/review/__init__.py b/app/backend/features/review/__init__.py index aff8464..27f2ca7 100644 --- a/app/backend/features/review/__init__.py +++ b/app/backend/features/review/__init__.py @@ -1,3 +1,23 @@ -from .trade_journal import EMOTIONS, TRADE_ACTIONS, TradeJournalService +from .agent import ReviewAssistantError, stream_review_assistant +from .http import ReviewHttpMixin +from .repository import ReviewRepositoryMixin +from .service import ReviewServiceMixin -__all__ = ["EMOTIONS", "TRADE_ACTIONS", "TradeJournalService"] +__all__ = [ + "EMOTIONS", + "ReviewAssistantError", + "ReviewHttpMixin", + "ReviewRepositoryMixin", + "ReviewServiceMixin", + "TRADE_ACTIONS", + "TradeJournalService", + "stream_review_assistant", +] + + +def __getattr__(name: str): + if name in {"EMOTIONS", "TRADE_ACTIONS", "TradeJournalService"}: + from . import trade_journal + + return getattr(trade_journal, name) + raise AttributeError(name) diff --git a/app/backend/features/review/agent.py b/app/backend/features/review/agent.py new file mode 100644 index 0000000..94396ac --- /dev/null +++ b/app/backend/features/review/agent.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import json +import urllib.error +import urllib.request +from collections.abc import Iterator +from typing import Any + +from llm_stream import OpenAIStreamAccumulator + + +class ReviewAssistantError(RuntimeError): + pass + + +def stream_review_assistant( + context: dict[str, Any], + question: str, + history: list[dict[str, str]], + api_key: str, + base_url: str, + model: str, + timeout: int = 120, +) -> Iterator[str]: + if not api_key or not model: + raise ReviewAssistantError("智能解读服务尚未配置。") + messages = [{"role": "system", "content": _system_prompt(context)}] + messages.extend(history[-12:]) + messages.append({"role": "user", "content": question}) + request = urllib.request.Request( + f"{base_url.rstrip('/')}/chat/completions", + data=json.dumps( + {"model": model, "messages": messages, "stream": True}, ensure_ascii=False + ).encode("utf-8"), + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}", + "User-Agent": "XiaobaiReviewWeb/1.0", + "Accept": "text/event-stream", + }, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + yielded = False + accumulator = OpenAIStreamAccumulator() + for raw_line in response: + line = raw_line.decode("utf-8", errors="replace").strip() + if not line or line.startswith(":"): + continue + if line.startswith("data:"): + line = line[5:].strip() + if line == "[DONE]": + break + try: + payload = json.loads(line) + except json.JSONDecodeError: + continue + choices = payload.get("choices") or [] + if not choices: + continue + choice = choices[0] or {} + content = accumulator.feed(choice) + if content: + yielded = True + yield str(content) + if not yielded: + raise ReviewAssistantError("智能解读未返回有效内容。") + except urllib.error.HTTPError as exc: + raise ReviewAssistantError(f"智能解读服务暂不可用({exc.code})。") from exc + except (urllib.error.URLError, TimeoutError, OSError) as exc: + raise ReviewAssistantError("智能解读连接中断,请稍后重试。") from exc + + +def _system_prompt(context: dict[str, Any]) -> str: + context_json = json.dumps(context, ensure_ascii=False, separators=(",", ":")) + return f""" +你是“小白复盘”的统一复盘助手。你负责把网页中已经存在的市场统计、策略跟踪、提醒、复盘笔记和手工交易日志连接起来,帮助用户复盘和形成下一步观察计划。 + +最高优先级规则: +1. 只能使用下方“网页复盘数据”,数据缺失就明确说明,不得补造行情、交易或胜率。 +2. 不自动下单,不声称已执行任何操作,不修改策略、提醒、笔记或交易日志。 +3. 不承诺收益,不给无条件买卖指令。建议必须写成条件、失效条件和风险边界。 +4. 区分市场事实、用户记录和你的推断。引用数字时写明数据日期。 +5. 优先结合用户自己的策略跟踪与交易日志寻找可验证的重复模式;样本不足时明确标注。 +6. 使用中文,先直接回答,再给数据依据和下一步观察。避免空泛口号,不展示模型、接口或内部工程信息。 +7. 控制在 800 个中文字符以内,除非用户明确要求展开。 + +网页复盘数据: +{context_json} +""".strip() diff --git a/app/backend/features/review/http.py b/app/backend/features/review/http.py new file mode 100644 index 0000000..ad66e52 --- /dev/null +++ b/app/backend/features/review/http.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import json +from datetime import date +from http import HTTPStatus + +from backend.bootstrap.config import normalize_date, validate_stock_code, validate_text +from backend.features.review.agent import ReviewAssistantError + + +class ReviewHttpMixin: + def save_trade_entry(self) -> None: + try: + body = self.read_json_body() + self.send_json( + {"ok": True, **self.application_service.save_trade_entry(body)}, + HTTPStatus.CREATED, + ) + except (ValueError, json.JSONDecodeError) as exc: + self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) + + def stream_assistant_chat(self) -> None: + try: + body = self.read_json_body() + stream = self.application_service.assistant_stream(body) + except (ValueError, json.JSONDecodeError) as exc: + self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) + return + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "application/x-ndjson; charset=utf-8") + self.send_header("Cache-Control", "no-cache, no-transform") + self.send_header("X-Accel-Buffering", "no") + self.send_header("Connection", "close") + self.end_headers() + try: + for chunk in stream: + self._write_stream_event({"type": "delta", "content": chunk}) + self._write_stream_event({"type": "done"}) + except (ValueError, ReviewAssistantError) as exc: + self._write_stream_event({"type": "error", "error": str(exc)}) + except (BrokenPipeError, ConnectionResetError): + pass + finally: + self.close_connection = True + + def save_watchlist(self) -> None: + try: + body = self.read_json_body() + code = validate_stock_code(str(body.get("code", ""))) + name = validate_text(body.get("name"), "股票名称", 30, required=True) + sector = validate_text(body.get("sector"), "所属板块", 50) + color = str(body.get("color") or "red") + if color not in {"red", "blue", "green", "amber"}: + raise ValueError("标记颜色不支持。") + remark = validate_text(body.get("remark"), "跟踪备注", 240) + service = self.application_service + service.database.save_watchlist( + service.current_user_id, code, name, sector, color, remark + ) + self.send_json( + { + "ok": True, + "items": service.database.list_watchlist(service.current_user_id), + } + ) + except (ValueError, json.JSONDecodeError) as exc: + self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) + + def save_note(self) -> None: + try: + body = self.read_json_body() + code = str(body.get("code") or "").strip() + if code: + code = validate_stock_code(code) + stock_name = validate_text(body.get("stock_name"), "股票名称", 30) + trade_date = normalize_date(str(body.get("trade_date") or date.today().isoformat())) + summary = validate_text(body.get("summary"), "盘面摘要", 500) + content = validate_text(body.get("content"), "复盘内容", 5000) + plan = validate_text(body.get("plan"), "明日计划", 2000) + if not summary and not content and not plan: + raise ValueError("每日复盘内容不能全部为空。") + raw_id = body.get("id") + note_id = int(raw_id) if raw_id else None + service = self.application_service + saved_id = service.database.save_note( + service.current_user_id, + code, + stock_name, + trade_date, + content, + plan, + note_id, + summary=summary, + ) + self.send_json({"ok": True, "id": saved_id}) + except (ValueError, TypeError, json.JSONDecodeError) as exc: + self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) diff --git a/app/backend/features/review/repository.py b/app/backend/features/review/repository.py new file mode 100644 index 0000000..d06b0c1 --- /dev/null +++ b/app/backend/features/review/repository.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +import json +from datetime import datetime +from typing import Any + + +class ReviewRepositoryMixin: + def list_watchlist(self, user_id: int) -> list[dict[str, Any]]: + with self.connect() as connection: + rows = connection.execute( + """ + SELECT code, name, sector, color, remark, created_at, updated_at + FROM watchlist WHERE user_id = ? ORDER BY updated_at DESC, code + """, + (int(user_id),), + ).fetchall() + return [dict(row) for row in rows] + + def save_watchlist( + self, user_id: int, code: str, name: str, sector: str, color: str, + remark: str | None = None, + ) -> None: + now = datetime.now().astimezone().isoformat(timespec="seconds") + with self.connect() as connection: + existing = connection.execute( + "SELECT remark FROM watchlist WHERE user_id = ? AND code = ?", + (int(user_id), code), + ).fetchone() + saved_remark = ( + str(existing["remark"] or "") if remark is None and existing else str(remark or "") + ) + connection.execute( + """ + INSERT INTO watchlist + (user_id, code, name, sector, color, remark, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id, code) DO UPDATE SET + name = excluded.name, + sector = excluded.sector, + color = excluded.color, + remark = excluded.remark, + updated_at = excluded.updated_at + """, + (int(user_id), code, name, sector, color, saved_remark, now, now), + ) + + def watchlist_price_history( + self, codes: list[str], end_date: str, limit_per_code: int = 6 + ) -> dict[str, list[dict[str, Any]]]: + result: dict[str, list[dict[str, Any]]] = {} + if not codes: + return result + with self.connect() as connection: + for code in codes: + rows = connection.execute( + """ + SELECT trade_date, ts_code, close, pct_chg + FROM daily_bars + WHERE substr(ts_code, 1, 6) = ? AND trade_date <= ? + ORDER BY trade_date DESC LIMIT ? + """, + (str(code), end_date, int(limit_per_code)), + ).fetchall() + result[str(code)] = [dict(row) for row in reversed(rows)] + return result + + def delete_watchlist(self, user_id: int, code: str) -> bool: + with self.connect() as connection: + cursor = connection.execute( + "DELETE FROM watchlist WHERE user_id = ? AND code = ?", + (int(user_id), code), + ) + return cursor.rowcount > 0 + + def list_notes( + self, + user_id: int, + code: str = "", + trade_date: str = "", + scope: str = "all", + ) -> list[dict[str, Any]]: + clauses: list[str] = ["user_id = ?"] + parameters: list[Any] = [int(user_id)] + if scope == "daily": + clauses.append("code = ''") + elif scope == "stock": + clauses.append("code <> ''") + if code: + clauses.append("code = ?") + parameters.append(code) + if trade_date: + clauses.append("trade_date = ?") + parameters.append(trade_date) + where = f"WHERE {' AND '.join(clauses)}" if clauses else "" + with self.connect() as connection: + rows = connection.execute( + f""" + SELECT id, code, stock_name, trade_date, summary, content, plan, created_at, updated_at + FROM review_notes {where} + ORDER BY trade_date DESC, updated_at DESC, id DESC LIMIT 200 + """, + parameters, + ).fetchall() + return [dict(row) for row in rows] + + def save_note( + self, + user_id: int, + code: str, + stock_name: str, + trade_date: str, + content: str, + plan: str, + note_id: int | None = None, + summary: str = "", + ) -> int: + now = datetime.now().astimezone().isoformat(timespec="seconds") + with self.connect() as connection: + if note_id: + cursor = connection.execute( + """ + UPDATE review_notes + SET code = ?, stock_name = ?, trade_date = ?, summary = ?, content = ?, plan = ?, updated_at = ? + WHERE id = ? AND user_id = ? + """, + (code, stock_name, trade_date, summary, content, plan, now, note_id, int(user_id)), + ) + if cursor.rowcount == 0: + raise ValueError("复盘笔记不存在。") + return note_id + cursor = connection.execute( + """ + INSERT INTO review_notes + (user_id, code, stock_name, trade_date, summary, content, plan, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (int(user_id), code, stock_name, trade_date, summary, content, plan, now, now), + ) + return int(cursor.lastrowid) + + def delete_note(self, user_id: int, note_id: int) -> bool: + with self.connect() as connection: + cursor = connection.execute( + "DELETE FROM review_notes WHERE id = ? AND user_id = ?", + (note_id, int(user_id)), + ) + return cursor.rowcount > 0 + + def save_trade_entry( + self, + user_id: int, + trade_date: str, + code: str, + name: str, + action: str, + price: float, + quantity: int, + position_pct: float, + pnl_amount: float | None, + pnl_pct: float | None, + thesis: str, + execution: str, + emotion: str, + tags: list[str], + trade_id: int | None = None, + ) -> int: + now = datetime.now().astimezone().isoformat(timespec="seconds") + tags_json = json.dumps(tags, ensure_ascii=False, separators=(",", ":")) + with self.connect() as connection: + if trade_id: + cursor = connection.execute( + """ + UPDATE trade_entries SET + trade_date=?, code=?, name=?, action=?, price=?, quantity=?, + position_pct=?, pnl_amount=?, pnl_pct=?, thesis=?, execution=?, + emotion=?, tags=?, updated_at=? + WHERE id=? AND user_id=? + """, + ( + trade_date, code, name, action, price, quantity, position_pct, + pnl_amount, pnl_pct, thesis, execution, emotion, tags_json, now, + int(trade_id), int(user_id), + ), + ) + if cursor.rowcount == 0: + raise ValueError("交易记录不存在或无权修改。") + return int(trade_id) + cursor = connection.execute( + """ + INSERT INTO trade_entries + (user_id, trade_date, code, name, action, price, quantity, + position_pct, pnl_amount, pnl_pct, thesis, execution, emotion, + tags, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + int(user_id), trade_date, code, name, action, price, quantity, + position_pct, pnl_amount, pnl_pct, thesis, execution, emotion, + tags_json, now, now, + ), + ) + return int(cursor.lastrowid) + + def list_trade_entries( + self, user_id: int, start_date: str = "", end_date: str = "", code: str = "", + limit: int = 300, + ) -> list[dict[str, Any]]: + clauses = ["user_id = ?"] + parameters: list[Any] = [int(user_id)] + if start_date: + clauses.append("trade_date >= ?") + parameters.append(start_date) + if end_date: + clauses.append("trade_date <= ?") + parameters.append(end_date) + if code: + clauses.append("code = ?") + parameters.append(code) + parameters.append(max(1, min(1000, int(limit)))) + with self.connect() as connection: + rows = connection.execute( + f""" + SELECT * FROM trade_entries WHERE {' AND '.join(clauses)} + ORDER BY trade_date DESC, id DESC LIMIT ? + """, + parameters, + ).fetchall() + return [dict(row) for row in rows] + + def delete_trade_entry(self, user_id: int, trade_id: int) -> bool: + with self.connect() as connection: + cursor = connection.execute( + "DELETE FROM trade_entries WHERE id = ? AND user_id = ?", + (int(trade_id), int(user_id)), + ) + return cursor.rowcount > 0 + + def save_assistant_exchange( + self, user_id: int, question: str, answer: str, context_date: str + ) -> None: + now = datetime.now().astimezone().isoformat(timespec="seconds") + with self.connect() as connection: + connection.executemany( + """ + INSERT INTO assistant_messages + (user_id, role, content, context_date, created_at) + VALUES (?, ?, ?, ?, ?) + """, + [ + (int(user_id), "user", question, context_date, now), + (int(user_id), "assistant", answer, context_date, now), + ], + ) + connection.execute( + """ + DELETE FROM assistant_messages WHERE user_id = ? AND id NOT IN ( + SELECT id FROM assistant_messages + WHERE user_id = ? ORDER BY id DESC LIMIT 200 + ) + """, + (int(user_id), int(user_id)), + ) + + def list_assistant_messages(self, user_id: int, limit: int = 100) -> list[dict[str, Any]]: + with self.connect() as connection: + rows = connection.execute( + """ + SELECT role, content, context_date, created_at FROM assistant_messages + WHERE user_id = ? ORDER BY id DESC LIMIT ? + """, + (int(user_id), max(1, min(200, int(limit)))), + ).fetchall() + return [dict(row) for row in reversed(rows)] + + def delete_assistant_messages(self, user_id: int) -> int: + with self.connect() as connection: + cursor = connection.execute( + "DELETE FROM assistant_messages WHERE user_id = ?", (int(user_id),) + ) + return int(cursor.rowcount) diff --git a/app/backend/features/review/service.py b/app/backend/features/review/service.py new file mode 100644 index 0000000..bc77cd9 --- /dev/null +++ b/app/backend/features/review/service.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +from datetime import date, datetime, timedelta +from typing import Any + +from backend.bootstrap.config import normalize_date, tushare_code, validate_text +from backend.data.providers.tushare_client import TushareError +from backend.features.review.agent import ReviewAssistantError, stream_review_assistant + + +class ReviewServiceMixin: + def trade_entries( + self, start_date: str = "", end_date: str = "", code: str = "" + ) -> dict[str, Any]: + return self.trade_journal.list_entries( + self.current_user_id, start_date, end_date, code + ) + + def review_watchlist(self, trade_date: str) -> dict[str, Any]: + normalized_date = normalize_date(trade_date) + items = self.database.list_watchlist(self.current_user_id) + if not items: + return {"items": [], "trade_date": normalized_date} + + resolved_date = normalized_date + if self.configured: + try: + client = self._tushare_client() + resolved_date, _ = client.resolve_trade_context(normalized_date) + history = self.database.watchlist_price_history( + [str(item["code"]) for item in items], resolved_date + ) + missing_codes = [ + str(item["code"]) for item in items + if len(history.get(str(item["code"])) or []) < 6 + ] + start_date = ( + datetime.strptime(resolved_date, "%Y%m%d") - timedelta(days=24) + ).strftime("%Y%m%d") + for code in missing_codes: + rows = client.query( + "daily", + { + "ts_code": tushare_code(code), + "start_date": start_date, + "end_date": resolved_date, + }, + "ts_code,trade_date,open,high,low,close,pct_chg,vol,amount", + ) + if rows: + self.database.upsert_daily_bars(rows) + if missing_codes: + history = self.database.watchlist_price_history( + [str(item["code"]) for item in items], resolved_date + ) + except (TushareError, ValueError): + history = self.database.watchlist_price_history( + [str(item["code"]) for item in items], resolved_date + ) + else: + history = self.database.watchlist_price_history( + [str(item["code"]) for item in items], resolved_date + ) + + auction_scores: dict[str, Any] = {} + try: + auction = self.auction_center(normalized_date, False) + auction_scores = { + str(row.get("code") or ""): row.get("attention_score") + for row in (auction.get("watchlist_rows") or []) + if row.get("available", True) + } + except (TushareError, ValueError): + pass + + enriched = [] + for item in items: + code = str(item.get("code") or "") + bars = history.get(code) or [] + latest = bars[-1] if bars else {} + close = float(latest.get("close") or 0) + base_close = float(bars[-6].get("close") or 0) if len(bars) >= 6 else 0 + enriched.append( + { + **item, + "change": ( + round(float(latest.get("pct_chg") or 0), 2) if latest else None + ), + "return_5d": ( + round((close / base_close - 1) * 100, 2) + if close > 0 and base_close > 0 else None + ), + "attention_score": auction_scores.get(code), + "market_date": str(latest.get("trade_date") or ""), + } + ) + return {"items": enriched, "trade_date": resolved_date} + + def save_trade_entry(self, payload: dict[str, Any]) -> dict[str, Any]: + trade_id = self.trade_journal.save(self.current_user_id, payload) + return {"id": trade_id, **self.trade_entries()} + + def delete_trade_entry(self, trade_id: int) -> dict[str, Any]: + deleted = self.trade_journal.delete(self.current_user_id, trade_id) + return {"deleted": deleted, **self.trade_entries()} + + def assistant_messages(self) -> list[dict[str, Any]]: + return self.database.list_assistant_messages(self.current_user_id) + + def clear_assistant_messages(self) -> int: + return self.database.delete_assistant_messages(self.current_user_id) + + def assistant_stream(self, payload: dict[str, Any]): + question = validate_text(payload.get("question"), "问题", 2000, required=True) + trade_date = normalize_date( + str(payload.get("trade_date") or date.today().isoformat()) + ) + context = self._assistant_context(trade_date) + history = [ + {"role": item["role"], "content": str(item["content"])[:4000]} + for item in self.assistant_messages()[-12:] + if item.get("role") in {"user", "assistant"} + ] + def generate(): + answer_parts: list[str] = [] + events = self.llm_gateway.stream( + "assistant", + "review-assistant-v1", + lambda profile: stream_review_assistant( + context, + question, + history, + profile.api_key, + profile.base_url, + profile.model, + ), + (ReviewAssistantError,), + ) + for event in events: + if event.kind == "delta": + chunk = str(event.value or "") + answer_parts.append(chunk) + yield chunk + elif event.kind == "complete": + self.database.save_assistant_exchange( + self.current_user_id, + question, + "".join(answer_parts).strip(), + trade_date, + ) + + return generate() + + def _assistant_context(self, trade_date: str) -> dict[str, Any]: + dashboard = self.get_dashboard(trade_date) + actual_date = normalize_date( + str((dashboard.get("meta") or {}).get("trade_date") or trade_date) + ) + sentiment = self.sentiment_history(actual_date, 10) + tracking = self.strategy_tracking.list_tracking(self.current_user_id, 5) + alerts = self.alert_service.list_alerts( + self.current_user_id, "all", date.today().isoformat() + ) + trades = self.trade_journal.list_entries( + self.current_user_id, end_date=actual_date + ) + return { + "data_date": actual_date, + "market": { + "overview": dashboard.get("overview") or {}, + "top_sectors": (dashboard.get("sectors") or [])[:8], + "limit_performance": dashboard.get("limit_performance") or {}, + "sentiment_history": (sentiment.get("rows") or [])[-10:], + }, + "personal": { + "watchlist": self.database.list_watchlist(self.current_user_id)[:30], + "review_notes": self.database.list_notes( + self.current_user_id, scope="daily" + )[:10], + "strategy_tracking": { + "summary": tracking.get("summary") or {}, + "batches": (tracking.get("batches") or [])[:5], + }, + "alerts": (alerts.get("items") or [])[:20], + "trade_summary": trades.get("summary") or {}, + "trade_entries": (trades.get("items") or [])[:30], + }, + } diff --git a/app/database.py b/app/database.py index 2f75274..180f691 100644 --- a/app/database.py +++ b/app/database.py @@ -8,6 +8,7 @@ from typing import Any from backend.database import MIGRATIONS, MigrationRunner, SQLiteConnectionFactory from backend.features.accounts.repository import AccountRepositoryMixin +from backend.features.alerts.repository import AlertRepositoryMixin from backend.features.auction.repository import AuctionRepositoryMixin from backend.features.dragon_tiger.repository import DragonTigerRepositoryMixin from backend.features.heaven.repository import HeavenRepositoryMixin @@ -15,6 +16,7 @@ from backend.features.market.repository import MarketRepositoryMixin from backend.features.mentor.repository import MentorRepositoryMixin from backend.features.pools.repository import PoolRepositoryMixin from backend.features.popularity.repository import PopularityRepositoryMixin +from backend.features.review.repository import ReviewRepositoryMixin from backend.features.screener.repository import ScreenerRepositoryMixin from backend.features.system.repository import SystemSettingsRepositoryMixin from backend.llm.repository import LLMAuditRepositoryMixin @@ -22,6 +24,7 @@ from backend.llm.repository import LLMAuditRepositoryMixin class ReviewDatabase( AccountRepositoryMixin, + AlertRepositoryMixin, AuctionRepositoryMixin, DragonTigerRepositoryMixin, HeavenRepositoryMixin, @@ -29,6 +32,7 @@ class ReviewDatabase( MentorRepositoryMixin, PoolRepositoryMixin, PopularityRepositoryMixin, + ReviewRepositoryMixin, ScreenerRepositoryMixin, SystemSettingsRepositoryMixin, LLMAuditRepositoryMixin, @@ -662,147 +666,6 @@ class ReviewDatabase( MigrationRunner().apply(connection, MIGRATIONS) - def list_watchlist(self, user_id: int) -> list[dict[str, Any]]: - with self.connect() as connection: - rows = connection.execute( - """ - SELECT code, name, sector, color, remark, created_at, updated_at - FROM watchlist WHERE user_id = ? ORDER BY updated_at DESC, code - """, - (int(user_id),), - ).fetchall() - return [dict(row) for row in rows] - - def save_watchlist( - self, user_id: int, code: str, name: str, sector: str, color: str, - remark: str | None = None, - ) -> None: - now = datetime.now().astimezone().isoformat(timespec="seconds") - with self.connect() as connection: - existing = connection.execute( - "SELECT remark FROM watchlist WHERE user_id = ? AND code = ?", - (int(user_id), code), - ).fetchone() - saved_remark = ( - str(existing["remark"] or "") if remark is None and existing else str(remark or "") - ) - connection.execute( - """ - INSERT INTO watchlist - (user_id, code, name, sector, color, remark, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(user_id, code) DO UPDATE SET - name = excluded.name, - sector = excluded.sector, - color = excluded.color, - remark = excluded.remark, - updated_at = excluded.updated_at - """, - (int(user_id), code, name, sector, color, saved_remark, now, now), - ) - - def watchlist_price_history( - self, codes: list[str], end_date: str, limit_per_code: int = 6 - ) -> dict[str, list[dict[str, Any]]]: - result: dict[str, list[dict[str, Any]]] = {} - if not codes: - return result - with self.connect() as connection: - for code in codes: - rows = connection.execute( - """ - SELECT trade_date, ts_code, close, pct_chg - FROM daily_bars - WHERE substr(ts_code, 1, 6) = ? AND trade_date <= ? - ORDER BY trade_date DESC LIMIT ? - """, - (str(code), end_date, int(limit_per_code)), - ).fetchall() - result[str(code)] = [dict(row) for row in reversed(rows)] - return result - - def delete_watchlist(self, user_id: int, code: str) -> bool: - with self.connect() as connection: - cursor = connection.execute( - "DELETE FROM watchlist WHERE user_id = ? AND code = ?", - (int(user_id), code), - ) - return cursor.rowcount > 0 - def list_notes( - self, - user_id: int, - code: str = "", - trade_date: str = "", - scope: str = "all", - ) -> list[dict[str, Any]]: - clauses: list[str] = ["user_id = ?"] - parameters: list[Any] = [int(user_id)] - if scope == "daily": - clauses.append("code = ''") - elif scope == "stock": - clauses.append("code <> ''") - if code: - clauses.append("code = ?") - parameters.append(code) - if trade_date: - clauses.append("trade_date = ?") - parameters.append(trade_date) - where = f"WHERE {' AND '.join(clauses)}" if clauses else "" - with self.connect() as connection: - rows = connection.execute( - f""" - SELECT id, code, stock_name, trade_date, summary, content, plan, created_at, updated_at - FROM review_notes {where} - ORDER BY trade_date DESC, updated_at DESC, id DESC LIMIT 200 - """, - parameters, - ).fetchall() - return [dict(row) for row in rows] - - def save_note( - self, - user_id: int, - code: str, - stock_name: str, - trade_date: str, - content: str, - plan: str, - note_id: int | None = None, - summary: str = "", - ) -> int: - now = datetime.now().astimezone().isoformat(timespec="seconds") - with self.connect() as connection: - if note_id: - cursor = connection.execute( - """ - UPDATE review_notes - SET code = ?, stock_name = ?, trade_date = ?, summary = ?, content = ?, plan = ?, updated_at = ? - WHERE id = ? AND user_id = ? - """, - (code, stock_name, trade_date, summary, content, plan, now, note_id, int(user_id)), - ) - if cursor.rowcount == 0: - raise ValueError("复盘笔记不存在。") - return note_id - cursor = connection.execute( - """ - INSERT INTO review_notes - (user_id, code, stock_name, trade_date, summary, content, plan, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - (int(user_id), code, stock_name, trade_date, summary, content, plan, now, now), - ) - return int(cursor.lastrowid) - - def delete_note(self, user_id: int, note_id: int) -> bool: - with self.connect() as connection: - cursor = connection.execute( - "DELETE FROM review_notes WHERE id = ? AND user_id = ?", - (note_id, int(user_id)), - ) - return cursor.rowcount > 0 - - def list_sector_phase_overrides(self) -> dict[str, str]: with self.connect() as connection: @@ -832,10 +695,6 @@ class ReviewDatabase( (name,), ) return cursor.rowcount > 0 - - - - def list_wencai_saved_queries( self, user_id: int, limit: int = 30 ) -> list[dict[str, Any]]: @@ -885,241 +744,3 @@ class ReviewDatabase( (int(query_id), int(user_id)), ) return cursor.rowcount > 0 - - - def save_alert( - self, - user_id: int, - kind: str, - title: str, - content: str, - available_date: str, - code: str, - dedupe_key: str, - ) -> int: - now = datetime.now().astimezone().isoformat(timespec="seconds") - with self.connect() as connection: - connection.execute( - """ - INSERT INTO alerts - (user_id, kind, title, content, available_date, code, dedupe_key, - is_read, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?) - ON CONFLICT(user_id, dedupe_key) DO UPDATE SET - title=excluded.title, content=excluded.content, - available_date=excluded.available_date, updated_at=excluded.updated_at - """, - ( - int(user_id), kind, title, content, available_date, code, - dedupe_key, now, now, - ), - ) - row = connection.execute( - "SELECT id FROM alerts WHERE user_id = ? AND dedupe_key = ?", - (int(user_id), dedupe_key), - ).fetchone() - return int(row["id"]) - - def list_alerts( - self, user_id: int, as_of: str, unread_only: bool = False, limit: int = 100 - ) -> list[dict[str, Any]]: - with self.connect() as connection: - if unread_only: - rows = connection.execute( - """ - SELECT id, kind, title, content, available_date, code, is_read, - created_at, updated_at, read_at - FROM alerts - WHERE user_id = ? AND available_date <= ? AND is_read = 0 - ORDER BY available_date DESC, id DESC LIMIT ? - """, - (int(user_id), as_of, max(1, min(300, int(limit)))), - ).fetchall() - else: - rows = connection.execute( - """ - SELECT id, kind, title, content, available_date, code, is_read, - created_at, updated_at, read_at - FROM alerts WHERE user_id = ? - ORDER BY CASE WHEN available_date > ? THEN 0 ELSE 1 END, - is_read, available_date, id DESC LIMIT ? - """, - (int(user_id), as_of, max(1, min(300, int(limit)))), - ).fetchall() - return [{**dict(row), "is_read": bool(row["is_read"])} for row in rows] - - def count_unread_alerts(self, user_id: int, as_of: str) -> int: - with self.connect() as connection: - row = connection.execute( - """ - SELECT COUNT(*) AS total FROM alerts - WHERE user_id = ? AND available_date <= ? AND is_read = 0 - """, - (int(user_id), as_of), - ).fetchone() - return int(row["total"] if row else 0) - - def mark_alert_read(self, user_id: int, alert_id: int) -> bool: - now = datetime.now().astimezone().isoformat(timespec="seconds") - with self.connect() as connection: - cursor = connection.execute( - """ - UPDATE alerts SET is_read = 1, read_at = ?, updated_at = ? - WHERE id = ? AND user_id = ? - """, - (now, now, int(alert_id), int(user_id)), - ) - return cursor.rowcount > 0 - - def mark_all_alerts_read(self, user_id: int, as_of: str) -> int: - now = datetime.now().astimezone().isoformat(timespec="seconds") - with self.connect() as connection: - cursor = connection.execute( - """ - UPDATE alerts SET is_read = 1, read_at = ?, updated_at = ? - WHERE user_id = ? AND available_date <= ? AND is_read = 0 - """, - (now, now, int(user_id), as_of), - ) - return int(cursor.rowcount) - - def delete_alert(self, user_id: int, alert_id: int) -> bool: - with self.connect() as connection: - cursor = connection.execute( - "DELETE FROM alerts WHERE id = ? AND user_id = ?", - (int(alert_id), int(user_id)), - ) - return cursor.rowcount > 0 - - def save_trade_entry( - self, - user_id: int, - trade_date: str, - code: str, - name: str, - action: str, - price: float, - quantity: int, - position_pct: float, - pnl_amount: float | None, - pnl_pct: float | None, - thesis: str, - execution: str, - emotion: str, - tags: list[str], - trade_id: int | None = None, - ) -> int: - now = datetime.now().astimezone().isoformat(timespec="seconds") - tags_json = json.dumps(tags, ensure_ascii=False, separators=(",", ":")) - with self.connect() as connection: - if trade_id: - cursor = connection.execute( - """ - UPDATE trade_entries SET - trade_date=?, code=?, name=?, action=?, price=?, quantity=?, - position_pct=?, pnl_amount=?, pnl_pct=?, thesis=?, execution=?, - emotion=?, tags=?, updated_at=? - WHERE id=? AND user_id=? - """, - ( - trade_date, code, name, action, price, quantity, position_pct, - pnl_amount, pnl_pct, thesis, execution, emotion, tags_json, now, - int(trade_id), int(user_id), - ), - ) - if cursor.rowcount == 0: - raise ValueError("交易记录不存在或无权修改。") - return int(trade_id) - cursor = connection.execute( - """ - INSERT INTO trade_entries - (user_id, trade_date, code, name, action, price, quantity, - position_pct, pnl_amount, pnl_pct, thesis, execution, emotion, - tags, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - int(user_id), trade_date, code, name, action, price, quantity, - position_pct, pnl_amount, pnl_pct, thesis, execution, emotion, - tags_json, now, now, - ), - ) - return int(cursor.lastrowid) - - def list_trade_entries( - self, user_id: int, start_date: str = "", end_date: str = "", code: str = "", - limit: int = 300, - ) -> list[dict[str, Any]]: - clauses = ["user_id = ?"] - parameters: list[Any] = [int(user_id)] - if start_date: - clauses.append("trade_date >= ?") - parameters.append(start_date) - if end_date: - clauses.append("trade_date <= ?") - parameters.append(end_date) - if code: - clauses.append("code = ?") - parameters.append(code) - parameters.append(max(1, min(1000, int(limit)))) - with self.connect() as connection: - rows = connection.execute( - f""" - SELECT * FROM trade_entries WHERE {' AND '.join(clauses)} - ORDER BY trade_date DESC, id DESC LIMIT ? - """, - parameters, - ).fetchall() - return [dict(row) for row in rows] - - def delete_trade_entry(self, user_id: int, trade_id: int) -> bool: - with self.connect() as connection: - cursor = connection.execute( - "DELETE FROM trade_entries WHERE id = ? AND user_id = ?", - (int(trade_id), int(user_id)), - ) - return cursor.rowcount > 0 - - def save_assistant_exchange( - self, user_id: int, question: str, answer: str, context_date: str - ) -> None: - now = datetime.now().astimezone().isoformat(timespec="seconds") - with self.connect() as connection: - connection.executemany( - """ - INSERT INTO assistant_messages - (user_id, role, content, context_date, created_at) - VALUES (?, ?, ?, ?, ?) - """, - [ - (int(user_id), "user", question, context_date, now), - (int(user_id), "assistant", answer, context_date, now), - ], - ) - connection.execute( - """ - DELETE FROM assistant_messages WHERE user_id = ? AND id NOT IN ( - SELECT id FROM assistant_messages - WHERE user_id = ? ORDER BY id DESC LIMIT 200 - ) - """, - (int(user_id), int(user_id)), - ) - - def list_assistant_messages(self, user_id: int, limit: int = 100) -> list[dict[str, Any]]: - with self.connect() as connection: - rows = connection.execute( - """ - SELECT role, content, context_date, created_at FROM assistant_messages - WHERE user_id = ? ORDER BY id DESC LIMIT ? - """, - (int(user_id), max(1, min(200, int(limit)))), - ).fetchall() - return [dict(row) for row in reversed(rows)] - - def delete_assistant_messages(self, user_id: int) -> int: - with self.connect() as connection: - cursor = connection.execute( - "DELETE FROM assistant_messages WHERE user_id = ?", (int(user_id),) - ) - return int(cursor.rowcount) diff --git a/app/tests/test_preservation_slice_review_alerts.py b/app/tests/test_preservation_slice_review_alerts.py new file mode 100644 index 0000000..2c07b35 --- /dev/null +++ b/app/tests/test_preservation_slice_review_alerts.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import ast +import hashlib +import unittest +from pathlib import Path + +import assistant_agent +from backend.features.review import agent as canonical_agent + + +APP_ROOT = Path(__file__).resolve().parents[1] +ORIGINAL_ROOT = APP_ROOT.parent + +ALERT_SERVICE_METHODS = { + "alert_center", + "create_alert", + "mark_alert_read", + "mark_all_alerts_read", + "delete_alert", +} + +REVIEW_SERVICE_METHODS = { + "trade_entries", + "review_watchlist", + "save_trade_entry", + "delete_trade_entry", + "assistant_messages", + "clear_assistant_messages", + "assistant_stream", + "_assistant_context", +} + +ALERT_REPOSITORY_METHODS = { + "save_alert", + "list_alerts", + "count_unread_alerts", + "mark_alert_read", + "mark_all_alerts_read", + "delete_alert", +} + +REVIEW_REPOSITORY_METHODS = { + "list_watchlist", + "save_watchlist", + "watchlist_price_history", + "delete_watchlist", + "list_notes", + "save_note", + "delete_note", + "save_trade_entry", + "list_trade_entries", + "delete_trade_entry", + "save_assistant_exchange", + "list_assistant_messages", + "delete_assistant_messages", +} + +ALERT_HTTP_METHODS = {"save_alert"} +REVIEW_HTTP_METHODS = { + "save_trade_entry", + "stream_assistant_chat", + "save_watchlist", + "save_note", +} + + +def sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def class_methods(path: Path, class_name: str) -> dict[str, str]: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + owner = next( + node + for node in tree.body + if isinstance(node, ast.ClassDef) and node.name == class_name + ) + return { + node.name: ast.dump(node, include_attributes=False) + for node in owner.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + + +def top_level_definition(path: Path, name: str) -> str: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + node = next( + item + for item in tree.body + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) + and item.name == name + ) + return ast.dump(node, include_attributes=False) + + +class ReviewAlertsSliceSourceEquivalenceTests(unittest.TestCase): + def assert_methods_equal( + self, + original_path: Path, + original_class: str, + migrated_path: Path, + migrated_class: str, + expected: set[str], + ) -> None: + original = class_methods(original_path, original_class) + migrated = class_methods(migrated_path, migrated_class) + self.assertEqual(set(migrated), expected) + for name in sorted(expected): + self.assertEqual(migrated[name], original[name], name) + + def test_review_assistant_agent_is_an_exact_file(self) -> None: + self.assertEqual( + sha256(ORIGINAL_ROOT / "assistant_agent.py"), + sha256(APP_ROOT / "backend" / "features" / "review" / "agent.py"), + ) + + def test_review_assistant_compatibility_module_is_canonical(self) -> None: + self.assertIs(assistant_agent, canonical_agent) + + def test_alert_and_review_service_methods_are_exact_original_ast(self) -> None: + self.assert_methods_equal( + ORIGINAL_ROOT / "server.py", + "DashboardService", + APP_ROOT / "backend" / "features" / "alerts" / "facade.py", + "AlertServiceMixin", + ALERT_SERVICE_METHODS, + ) + self.assert_methods_equal( + ORIGINAL_ROOT / "server.py", + "DashboardService", + APP_ROOT / "backend" / "features" / "review" / "service.py", + "ReviewServiceMixin", + REVIEW_SERVICE_METHODS, + ) + + def test_alert_and_review_repository_methods_are_exact_original_ast(self) -> None: + self.assert_methods_equal( + ORIGINAL_ROOT / "database.py", + "ReviewDatabase", + APP_ROOT / "backend" / "features" / "alerts" / "repository.py", + "AlertRepositoryMixin", + ALERT_REPOSITORY_METHODS, + ) + self.assert_methods_equal( + ORIGINAL_ROOT / "database.py", + "ReviewDatabase", + APP_ROOT / "backend" / "features" / "review" / "repository.py", + "ReviewRepositoryMixin", + REVIEW_REPOSITORY_METHODS, + ) + + def test_existing_alert_and_trade_journal_services_preserve_original_classes(self) -> None: + self.assertEqual( + top_level_definition( + ORIGINAL_ROOT / "backend" / "features" / "alerts" / "service.py", + "AlertService", + ), + top_level_definition( + APP_ROOT / "backend" / "features" / "alerts" / "service.py", + "AlertService", + ), + ) + self.assertEqual( + top_level_definition( + ORIGINAL_ROOT / "backend" / "features" / "review" / "trade_journal.py", + "TradeJournalService", + ), + top_level_definition( + APP_ROOT / "backend" / "features" / "review" / "trade_journal.py", + "TradeJournalService", + ), + ) + + def test_original_classes_no_longer_duplicate_moved_methods(self) -> None: + remaining_service = class_methods( + APP_ROOT / "backend" / "application.py", "DashboardService" + ) + remaining_database = class_methods(APP_ROOT / "database.py", "ReviewDatabase") + remaining_http = class_methods( + APP_ROOT / "backend" / "application.py", "RequestHandler" + ) + self.assertTrue(ALERT_SERVICE_METHODS.isdisjoint(remaining_service)) + self.assertTrue(REVIEW_SERVICE_METHODS.isdisjoint(remaining_service)) + self.assertTrue(ALERT_REPOSITORY_METHODS.isdisjoint(remaining_database)) + self.assertTrue(REVIEW_REPOSITORY_METHODS.isdisjoint(remaining_database)) + self.assertTrue(ALERT_HTTP_METHODS.isdisjoint(remaining_http)) + self.assertTrue(REVIEW_HTTP_METHODS.isdisjoint(remaining_http)) + + def test_http_mixins_preserve_all_endpoints_without_global_service(self) -> None: + alerts = class_methods( + APP_ROOT / "backend" / "features" / "alerts" / "http.py", + "AlertHttpMixin", + ) + review = class_methods( + APP_ROOT / "backend" / "features" / "review" / "http.py", + "ReviewHttpMixin", + ) + self.assertEqual(set(alerts), ALERT_HTTP_METHODS) + self.assertEqual(set(review), REVIEW_HTTP_METHODS) + for path in ( + APP_ROOT / "backend" / "features" / "alerts" / "http.py", + APP_ROOT / "backend" / "features" / "review" / "http.py", + ): + source = path.read_text(encoding="utf-8") + self.assertNotIn("SERVICE.", source) + self.assertIn("self.application_service.", source) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/migration/evidence/slice-09/README.md b/docs/migration/evidence/slice-09/README.md new file mode 100644 index 0000000..dfa3a95 --- /dev/null +++ b/docs/migration/evidence/slice-09/README.md @@ -0,0 +1,60 @@ +# 切片 09:我的复盘、自选、笔记、交易日志、提醒与复盘助手 + +> 基线:`b3df070`(切片 08) +> 回档标签:`xiaobai-preservation-slice-09-20260731` +> 结论:源码、API、数据库、账户隔离、真实页面及全量回归通过;最终视觉仍等待全站人工验收 + +## 1. 原实现归位 + +本切片只机械移动原版复盘、提醒和复盘助手实现,没有从`next/`取用代码,也没有修改自选、笔记、交易日志、提醒、会员灰化、LLM 上下文或流式协议。 + +| 原位置 | 新的唯一实现位置 | 兼容方式 | +|---|---|---| +| `assistant_agent.py` | `app/backend/features/review/agent.py` | 根级模块指向同一模块对象 | +| `DashboardService`提醒方法 | `app/backend/features/alerts/facade.py` | `AlertServiceMixin` | +| `DashboardService`复盘方法 | `app/backend/features/review/service.py` | `ReviewServiceMixin` | +| `ReviewDatabase`提醒方法 | `app/backend/features/alerts/repository.py` | `AlertRepositoryMixin` | +| `ReviewDatabase`复盘方法 | `app/backend/features/review/repository.py` | `ReviewRepositoryMixin` | +| 五个 POST/流式入口 | `app/backend/features/alerts/http.py`、`review/http.py` | HTTP Mixin | + +原版已经存在的`AlertService`、`TradeJournalService`、Repository Port 与 SQLite Adapter 继续保留为唯一底层实现。本切片没有制造第二套提醒或交易日志服务。 + +## 2. 源码等价与必要适配 + +- `assistant_agent.py`与正式 Agent 文件 SHA-256 一致,根级兼容模块与正式 Agent 是同一模块对象。 +- 5 个提醒编排方法、8 个复盘编排方法、6 个提醒 Repository 方法和 13 个复盘 Repository 方法均与原版无位置信息 AST 一致。 +- 五个 HTTP 方法只把全局`SERVICE`改为 Mixin 的`self.application_service`;状态码、字段、异常和流式事件写法不变。 +- `backend.features.alerts`和`backend.features.review`仅对旧底层服务采用延迟导出,避免`database -> feature package -> sqlite adapter -> database`初始化循环;外部`from ... import AlertService/TradeJournalService`接口不变。 +- 公共`RequestHandler._write_stream_event`继续保留,因为问师和复盘助手共同使用,且现有测试直接覆盖;统一传输层留到切片 10。 + +## 3. API 与数据库差分 + +- 原版`8790`和迁移版`8791`使用正式数据库的两个临时副本,未写正式数据库。 +- 固定日期下自选、全部复盘笔记、交易日志、提醒中心及复盘助手历史共 5 个鉴权接口,状态码与业务 JSON 完全一致。 +- 两版均为 62 个 schema 对象;`watchlist`、`review_notes`、`trade_entries`、`alerts`、`assistant_messages`、`users`、`system_settings`和`llm_usage`逐行一致。 +- 真实浏览器在迁移版临时副本中新增一条交易记录,弹窗正确关闭并显示短提示“交易记录已保存”;没有写正式数据库。 +- 未调用真实`/api/assistant/chat`,避免外部模型波动和 LLM 用量写入;Agent、流式累积与权限路径由源码等价和单元测试覆盖。 + +## 4. 真实浏览器检查 + +- 1920×1080 检查我的复盘完整布局,自选 6 只、交易日志、每日复盘三个独立输入框和最近复盘均可访问,无横向溢出。 +- 添加自选与交易日志弹窗居中可用;交易日志保存后没有出现超长空弹窗。 +- 提醒中心正常展示筛选、新建提醒和提醒记录。 +- 管理员会员账号显示完整可用的复盘助手;临时普通账号显示相同界面、顶部“复盘助手仅对会员开放”,下方快捷问题、输入框和发送按钮均灰化禁用。 +- 临时普通账号自选和交易日志均为空,证明用户私有数据没有串到管理员账号。 +- 夜间模式背景为`rgb(18, 20, 22)`,1920×1080 页面无横向溢出,控制台无错误。 +- 截图`review-workspace-night-1920x1080.png` SHA-256:`bc803305a391930a0aafd68062bab6de86eca14612799eebfda220cf2ad0798e`。 + +## 5. 自动验证与保留边界 + +| 验证 | 结果 | +|---|---:| +| 原版`python -m unittest discover -s tests -q` | 231 项通过 | +| 迁移版`python -m unittest discover -s tests -q` | 287 项通过 | +| `python -m unittest tests.test_preservation_slice_review_alerts -q` | 7 项通过 | +| `npx.cmd playwright test --reporter=dot` | 45 项通过(2.2 分钟) | +| `git diff --check` | 通过 | + +- 前端 DOM、页面 JS、CSS 与移动端行为未改动,统一归位延至切片 10。 +- `demo_data.py`、`static/heaven-loading.js`、五个疑似无引用前端函数和`wencai_saved_queries`继续保留到切片 11 试删。 +- 没有修改`next/`、正式数据库、Docker/NAS 或`8765`服务。 diff --git a/docs/migration/evidence/slice-09/api-diff.json b/docs/migration/evidence/slice-09/api-diff.json new file mode 100644 index 0000000..6b54980 --- /dev/null +++ b/docs/migration/evidence/slice-09/api-diff.json @@ -0,0 +1,60 @@ +{ + "all_equal": true, + "endpoints": [ + { + "name": "review watchlist", + "method": "GET", + "endpoint": "/api/watchlist?trade_date=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "29c001cc6859312fd90082d0fb83818d263827e28ec21157b94c415b71c45078", + "migrated_sha256": "29c001cc6859312fd90082d0fb83818d263827e28ec21157b94c415b71c45078", + "equal": true, + "first_difference": null + }, + { + "name": "all review notes", + "method": "GET", + "endpoint": "/api/notes?scope=all", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "a2a1f08610d017de0e46080a1268f72b734eeb80f9cada311d7e03eb1416656a", + "migrated_sha256": "a2a1f08610d017de0e46080a1268f72b734eeb80f9cada311d7e03eb1416656a", + "equal": true, + "first_difference": null + }, + { + "name": "trade journal through fixed date", + "method": "GET", + "endpoint": "/api/trades?end_date=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "77287b34046e22748e94e61ead841e157f6b63f08de1b2fe61f5d715b2e989a4", + "migrated_sha256": "77287b34046e22748e94e61ead841e157f6b63f08de1b2fe61f5d715b2e989a4", + "equal": true, + "first_difference": null + }, + { + "name": "alert center through fixed date", + "method": "GET", + "endpoint": "/api/alerts?status=all&as_of=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "8807772f2b611ea8b95033aa19cae84f08b08e92d76fff8e3cd5b79966ea04f4", + "migrated_sha256": "8807772f2b611ea8b95033aa19cae84f08b08e92d76fff8e3cd5b79966ea04f4", + "equal": true, + "first_difference": null + }, + { + "name": "review assistant history", + "method": "GET", + "endpoint": "/api/assistant/messages", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "eef46741adfc3a9f76294d3b78f37a45f113092ac9d44ee77c7a038a88ff09a1", + "migrated_sha256": "eef46741adfc3a9f76294d3b78f37a45f113092ac9d44ee77c7a038a88ff09a1", + "equal": true, + "first_difference": null + } + ] +} diff --git a/docs/migration/evidence/slice-09/api-requests.json b/docs/migration/evidence/slice-09/api-requests.json new file mode 100644 index 0000000..4bc736b --- /dev/null +++ b/docs/migration/evidence/slice-09/api-requests.json @@ -0,0 +1,32 @@ +[ + { + "name": "review watchlist", + "method": "GET", + "endpoint": "/api/watchlist?trade_date=2026-07-30", + "payload": null + }, + { + "name": "all review notes", + "method": "GET", + "endpoint": "/api/notes?scope=all", + "payload": null + }, + { + "name": "trade journal through fixed date", + "method": "GET", + "endpoint": "/api/trades?end_date=2026-07-30", + "payload": null + }, + { + "name": "alert center through fixed date", + "method": "GET", + "endpoint": "/api/alerts?status=all&as_of=2026-07-30", + "payload": null + }, + { + "name": "review assistant history", + "method": "GET", + "endpoint": "/api/assistant/messages", + "payload": null + } +] diff --git a/docs/migration/evidence/slice-09/database-diff.json b/docs/migration/evidence/slice-09/database-diff.json new file mode 100644 index 0000000..bc921c6 --- /dev/null +++ b/docs/migration/evidence/slice-09/database-diff.json @@ -0,0 +1,75 @@ +{ + "all_equal": true, + "schema": { + "object_count": 62, + "original_sha256": "60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1", + "migrated_sha256": "60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1", + "equal": true + }, + "tables": [ + { + "table": "watchlist", + "original_count": 6, + "migrated_count": 6, + "original_sha256": "070161f5cdcc33addf289f11cb0ae54bfc0d669a39426fc33398b64512dba2fe", + "migrated_sha256": "070161f5cdcc33addf289f11cb0ae54bfc0d669a39426fc33398b64512dba2fe", + "equal": true + }, + { + "table": "review_notes", + "original_count": 3, + "migrated_count": 3, + "original_sha256": "86ed485c2ce922e3434f8ac742d38431ef430b5c22024cacb6f64f0e8e71ee98", + "migrated_sha256": "86ed485c2ce922e3434f8ac742d38431ef430b5c22024cacb6f64f0e8e71ee98", + "equal": true + }, + { + "table": "trade_entries", + "original_count": 0, + "migrated_count": 0, + "original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "equal": true + }, + { + "table": "alerts", + "original_count": 2, + "migrated_count": 2, + "original_sha256": "37b46fb4a754cf9c6179b1be88eed7f5a5398ddfc87160f41ded622ffc313c88", + "migrated_sha256": "37b46fb4a754cf9c6179b1be88eed7f5a5398ddfc87160f41ded622ffc313c88", + "equal": true + }, + { + "table": "assistant_messages", + "original_count": 0, + "migrated_count": 0, + "original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "equal": true + }, + { + "table": "users", + "original_count": 3, + "migrated_count": 3, + "original_sha256": "4a0f135bef8ebae454693d3f40e1157d84d814d18d849f33e2e760ed1d8a7f89", + "migrated_sha256": "4a0f135bef8ebae454693d3f40e1157d84d814d18d849f33e2e760ed1d8a7f89", + "equal": true + }, + { + "table": "system_settings", + "original_count": 1, + "migrated_count": 1, + "original_sha256": "86d333a5c7feaf7111cd79b2db29513326a9b0d3781607f78bf633579e2ca8e9", + "migrated_sha256": "86d333a5c7feaf7111cd79b2db29513326a9b0d3781607f78bf633579e2ca8e9", + "equal": true + }, + { + "table": "llm_usage", + "original_count": 72, + "migrated_count": 72, + "original_sha256": "03a070316a125cef904bbfb2bb06b06e792242d5713142e70354402c741393c3", + "migrated_sha256": "03a070316a125cef904bbfb2bb06b06e792242d5713142e70354402c741393c3", + "equal": true + } + ] +} diff --git a/docs/migration/evidence/slice-09/review-workspace-night-1920x1080.png b/docs/migration/evidence/slice-09/review-workspace-night-1920x1080.png new file mode 100644 index 0000000000000000000000000000000000000000..f0f8acd560891e4420c1a1258c524e0b0362ac92 GIT binary patch literal 100490 zcmeFZ2UL?w*D#7*5k*QsX=(x}p-BlywLoa02&53IbVvv&RRQtXkd6TZ0s_a-Qb;gV z69iOx7f9%!A|Sm<6A>>*-|zd*dDs81b^mq$Th_W?vexs=JbTZ~o;~}S*)93m|MMf) z2@?Zj1Fk)LxVZN0ez|@Qap`d#+<)NUf&B*$9yoaD(80spC%Czf9N`u`&dYN`Sn%XY zVL>4w5pk*0BBCHMA)zyJXF$^DWWX|~PRn1AKYu~$yv+H(I@xpR&>`-_+ydO(0_R19 zM9%*&e}2B<;yb+m;-NqG?E!M_<=eB5Z_m#lhd;Qm8gd-m?z&v$_Tpukz{q%J|FE0%a5NYx7N?4Qi&KBVk& z6QFXhSm^2tVZ>A7TS>LR+_A&%CGS2hNkQ}i9xUI_(RU5r?N?-Xz+a#L7;~3fyZs(G zxC>L_+l6rL<=P$b@GfP4VetJ0=m8);NmVXX%@$91Q z<=e-{b%|?xolCXpSi`X#n+AwTbL9JGRj=~SKv%z!5HK*~(t23#Pp*Z|?K-EMeTf#H za?Hk%EsaIL<67z|cSFt}kEyv@8WWk>!*))RG=ni+!zAJ6-ysO|z)l6RSE2q*eG=if zLzj2QQ~fWxf0t5HyPY?CX7pjO=?mN5Yrubp|G!puC(-R70Mzhx{0vRs#Rmo~dd-8$ zT@(KZu?kIC(&o~c`CCPqL@I|DKl>Z8d!9lO#Zv4y`$j1imZSR+=SYkD{;i?_#SAo& zs=w}!L-@-IW6%hlcY-ix2P}tweR1w57m4paf|Ocp2+>L}ztSx|+FbXi{*h4qp}wMp z2`ev<&(}8;w!S|_Lx7A>OaI*1&U}_pqVU?#gi;sY>0|0hT`OAE!FX2{{p6Y_m*(5Q zEPlZU>2a`ZNo`^y^z|2J9CAk#3I?S=TW4S5YfR|S_WMe^IX%&;Fy|elI@Yn?5n zYB6vOX1`DufT&P+_>Fv2xqTc#Y3HAVhmKf}#`fn&U@u9W`mq*qa7V1BHVdB+KhsCd zNu~*|_+ZZQnarH==yxs^Q$>d82 zkU~zttF4<$UFbezO7E>(3;E(PJTe`ITh?xL!)B3Uu|TKShu>wbb^D3`5X9z=UW&dP z$B)gO4@(`U1_%IwWwt4XQuD)y9vpZS`mVS9l2i-ZpQ*W?*A^IEQGE0K37pWmX#qdC zG0We)yyEN<@oPMBAa3rt$$p^=Hva-k&Eq@FzsSGm{+Q(kD{F^=y4M$W*W-nYnwpw= zGlppiKv?~77u@qICyzwSJWI?C(r@L6UHjtylS}_U<)-SYwvB3uSPd1b`tN%jGBI0{=Km^3Ma$u3IqQ`sL2L~@V zaq+TWnM<_T+S+oTfBkH*KgZP?vwn*_*!pMCywO^aYRiO+Y}dC8!U&lm^plI(=1kSP z$sLrNjIb=!kN-wt!Qz4 z_VtesryDK|T%dHsYrkv;Lc~}F;2gjH3jLvW-uBZ`fj8byr<^;WWr_F1c9Bx_+Zvd- z9|#M!t-E%Rh?#?=BDSBdZwT?t5Q(apc|7S?ulRi<`X|@6;*P*^N3D?5$DdkLYYH}l zmI4y*Drn!S8G5QIuGDpV)q@gwq`=+Kotn-gy-Mivmi?X0I+{@ecl$}s(T(C`FTsp?27zcnj>ie11+7DKPx3PSD=;Q1*N-AtXEY_tVB|^+wB7uHZdou{O#4-j{ zLD~!;^X-JwSPD_Tr*7e;j8|aTQjCq#RvU(Jwp)hG*_3N{i&?LWB2i|6B`&)HDR;4) zH+o0dd+<7E%M8hQ3oG1#s7M-&;?k5ULu9`nToa|Rr9|OwGk#MHn1WGT4&J`VMX5PV zmVl^$4{=I)GrTT+Qjp5KsBJ?WyLAU3w$v^s16l-?`L;kr$F!B4kL4uuM!Kgti3!xp8;&FED)h0b z&HC*@c0`A3-|p^oKg(VEbW2T?cWE%@)LMLysZ!Z#+N1R_I|2#}WVK{YRU!9%-Tcq3 z`iS=(>C^GLx%#Em$-E8OR%Z_O?aPnM&lWQNAb4BGvONioFi>DgR@WhB5N6SXnD`M1 znMiAE3*jZ^^O8Ee$wDfYs6l7l7FA>Ltdw;16|_#iu2A_f`s31rlTu>JV+py&GME&D z99IN(g-WodOvS`xhFP^lkq3R3wSj3*+Eu&;+hMuT1VgnfO(U%Io6~}&3S&Pn zsvhVZi0a111M*$mjQ62 z0j&v$yZkvc7pPb%L;2)jFWsKzYX=6?7E)^#^76Vv37Fch^GT0fY*OM+&Lc`ZY9`ym zhx`IkQ&T@~++1Ax{(az^XV0aPovay_h4Hb2I%qSK#^RI1X|}U8dQ>GKw8yu89wtMT zAA9w%KNCBHQ3i4NNWD_&y+kyh0F#gVv7=gO^d6dcL zXPm$c$An0m!dSmf(X_kM>Eo{?fWr!vxthm9p3n@ww3daTT;dyJOQ?j2*Ady-R2u2+ zjg4!4a)HXTwo#PHDu$bV6C0OZCeVzi!Cd?pKl8b5rGHSZIO_$1b2B%FHIf1Yk64X( z_qh*MkhTqS; z$H2`X*)$$O!tq=-%{nC{mz*`?{f&=8#ihar0-zSL)#;J8p`j)=<0(>))(-c{CAiSp zgG_7IR4T&-2O_7Z0h6-uE%UZ!($IEILm$#wHM>6(S@p0_XLjN1@CVr1j??1S&au+X z2w^k3`>j#dSIqV=w@eR-btVCd6<|6P3N_3@ggP+lwVH&NQ91}ZlOHJq^ue!sORVcq zcid38VIT!eIk{g_h?9y+fQ*yxFzn35c7ZdzP^c!{ivl5&nMDK@;% zFsQtP1s2n&dUvzQ%eB^K%UIGxV+7&Ro{E+qBA}WbqT=)`aD5n&(2T8ik^Jyi@YCyF zLJaQ=DGTFRx!`*-lMI?a_4!1wJHm`Yt8nPc^+@=n7#>d}zVo*7M%X8!E$8AT#xZ3g zvpDxu_o6!mM|G2|O@=l2@688^Jzas!LO6&x*V8-j{_zBz%(O6HQi+{_^Ld$z$g zaP^DD!QdPeaI%%bKOIaXpkBw!=e>{9 zO}#ulUXWjlWu(0farr8@mTahPDbEoX#H|2CQmWlqv~@AxN1s#?f+u&BV(XHxeF{wV zG5G?KBqhx~q7p1|VN4(Lw=lZR36IE9l=d~1SN)~x7dn-+VZ$B`2rt+&tjj>reGDD*eFMVm)*Hj#=s7a#T~2pgg$ z5wU4KU4;uaNs3MN<^>+eBC9#*K>76R?Q7ebZ)Yyp%fFHUymvP)lR=9;U49df2Dvmt z`n0;6C@BayYqXE?wX^naT?_75*TBohPrS4sGpknBDk)d}c-!6qQgqmyBYRGET5;@{ zgw8|c^%7^&DS6}0&H+Pv?E4M zv)rexgHx64GIz12-bW~QPxnTbGrK4^Hm4T!qT}1iK4ui%Xmq@CAZ%cy(wO>_>%njox0KuGhj?fS#<|6s2zaulZnGQQA1(NP+WZM(T3WJ&)kOZ&=!LuX1#82k z^R6yv8pZ)RatBj&-Mb@W|63XG(xJYN##ULk^yTvMivYBkPbk%PQp2$v*r_YNK>lW$7_iVUc?97@t_nU!bO+H zP%|R%HBFRQim*`%2o^1!RMC$P5;`Ph&p!PYsB;o>+j35GE|Uxm2h7lt)Lcy7?GF2CQ3P^q~o}R8|o)kevL!f`?S=YA*tL z4WCPEmc&-H7z=GU$%J;<4M!ci^6>g*V7d|tp#~7hbq4gw$21t#VXapN5CDK*31+n30oV;~Ag7$`!62j!8O=b}eDMdhR#`fw0v6%vGxHLA z<6L)RthHu!r0LacWnyN zbUz&$k`a&>fNz?>$oJE$I0Cd?jX}37-1m-Hw zpJpzuywJA;5#*vH$}ZYF!AQ$q#3K4Ta&F^U>SK6ayq&SQn5n+c;kpPm!i+@buyf847EzI-> zt71b#s~?FjO_WN}z-rFCZoJ(CuTl{`yGU0}h_Upc9QM;?wyOmt1rRD{>Mw0Hmx%8O z9jbVHVlx5%`B=m35PVpjx0Y}U@A|@Zuu+~9px~(yAU_|res=ZP*=^Iej9hwdooZ-) z5pPz$W_}KqrSi2r>*DK5NYktv!eW>=a~fbSj*9QKxPOiH;8@spy-F?`5U*d}2c}Sp zoNU+n`$XrJ8((zum>-!wFZ#H-?$C(WMP zwARv#Y^k z=PR7^-j$BtPriKV-B)MG?(UfhL6`x;&IBPjEqysJ?j}X!Qs?9XpGH4mC?$UU z$@M1n#Prcr;$&4Ba=UjRLw$8_Z=DG5L)TEzZM%ItQdg{{WrW0yhD|&)2x-IdmHrG0 z2Wm$FRyT2AMYh)4u=lrC&819iO`4q}^JFCJvK{v0Tfqhi0YP%UUIf2DLncab!8q@3 zRN%9Yvk&{;I|FXrG2o+2od{fSHr}h$v^XItZ%2o-Ce3r_^snlGw+-zrj-@B>uXTE` zT4>f^1*cKbjx&$SQXnlOwm>2+X*LXPHKb>^d6&4lb$Qsof-lMA)27aTLUH3he6cAh zmJsisgRgL`K!>u*=jNO?L+&+h-i!2_yEewTG4^yBrFU(xf6Vrl7zgEP1}g!(`;RCS zM+7|4O8Z0dE-UESi&@3dlZ;+vR=Bn@$bEj5q@4w@SZ9B$q$P$`WS ztk0?CKiB?Hflp5qtaB^{raSXNzS-SrWsM}Ji-$gbo6uK#zuNmk|73v7c-qv4PE@%e zmAqT1IfHqRr5{o*-g0lMl5rIJIU+XAiiK zEzS7>YGu9@)ZWybxp)p{Ca$CR*(XDO0tg1qA#_G%^0>K(XC@hB$<^v^%Cs*&p#o3RxMrSCweV71{ zSdwfo_=ERYbs6D=buG^ko@z*px{xi5VQU2zjS^o`Y7-}a9v2WNZEdsD5Gjn>Ped+Gk zvy+N#HQYZ0*LYVZH!ppAl7o9;gVUAIyLbnH(P4KR(eZJ18uJ)gLR3{ud2z&KJL`6| zyshNZ{_tmrxmQk5mn+E}eSMaEO=o!DP}uV-0DKkk1Yu<|g*g-EmS7-HNPx1ycdf~> zrsOf2uVlN(+n$q#vzpLcPR?TZ^l_i4njZPBCl9RiZQL%Z5nNscDKoDZkitNn-|!Xv zv5V$;8!xPn@3>!T*!65&ZFq|68hcj9SrzbX&f5r>9p4lM_ejU&9wVcSqtOz}iQmJAtt7Cq$&M=_{`dH*0CYCk#1d={LoSfTE- zRW%X@x|#5CGetfDiTt$cmlYGDY2+DA!b{$rfQDFsfxz8U=S8b%`4}92!t@y&q60)y zAsk){)tD6fcua&l6y8Q^itc)>IpN zC%(VFo1%Scv8H`s46?Q*pR;*DO#p>{jOguVu^8+X&2u%{2}byq6ohFU7BB3X8aT^h z_gw6yB<^eI%D5>4o#qFk#Vy4WT{*ZC<+76#i|r9Tt`EG4YJxs%tYIzfGw1a!hQV2QOO;~{ zR2gTKoMp}Kk;6n`{T7Q~6s4}zFa6}=7-MH9>9i~gdFI<>Xw!xwhB9K5w~Y_Kw02F0 z9TB|Ev%^*kRWiO}?WFpwG-eQNbNbrm;5arPS&%HE=;0}A^}{QMf<3oAzj^8h$*=UZ z(V4E-29)BQ;{|nq)mg!t7NU-I{#CI-HOL00Lgq)@4efw^b<6v6y2T_Cl6m~m*|jnN zPt0UL+rN62fx?!oN!M3(xEdu%cJr9Y{28Qr1ff6|hKMjKGzTbAlqgb$8aQ*GQq}34 zy1r9HVo`>Gv5=QD*(T|5ZdF$+o}9@SJke4&GuQw6=}PtO+;mUbaeT{$Mkr_3=sYxK zs%3BkHPtIN9q60xMye$z+r%Dq0*&XQs*#kYMEjUNX87|VVK5j-!36s0lQ60|M9WXA z0xU3kXfMKwZ6WGrGDsRM#;n_clt-b}4MSveOv$nf-Dpkl6+-gD6)zG&Y>vH*nT#2w zR7$o)mA9m9QwQRBmn9w?Z_KGX{eaz6c+c5bl8T*RbxP@_y5anuZj`A3(KI{9kAWgi zITsB8_c|4X_3nRK2uB=G>yLv_Rh*4aiTTDSVyLHYi-tF4Ag3s7=CWRCW9i7vbd9l; z_?L3e{H>@i#SjnMd<#kHvfIq!^2L`@8Q*SfpWbe<4YAfe_~QgsedW{c9{03#AwT@s z)>*@;pIq;>k8&5(F16|AIPtuS<7=1Fw}QlMR51tp9H_wqwo%bT9|AlRuf2yFw5gS+ zzvxI4&g*GcaqP|G@bU4HJ#9On)vX1$zqWJrfMsqBf^7$zNJ=6?Wq4!d3AcE^ar;(= zShQR5oZZ=)ZZnlyZznY=r|9l62;IItG9B4cT*&~SgUhOR7IM|`@kL2i*HwRVjSU+f zrUov$%O%w%#27uBKwgqx1mUYUm@{r_#v*grVaz=unthoVSjjP%{Rp}&j)|adCW>K6_aG9kqjpbCj{K&iZ{Ds$=sV|VaCWf;@=9zBz(e-gB zDFGL?^@?coY$MXC*s-)Wu|CaT6#>(23Voqu|4IY(AuSsAzkJfV%^V{pDR$fV-CJWK=d$>mqw&U z4L;kn^w~8CLJb`3qml9p^GXbD)mvBXhnP#&IScI@jXoT7&*u)maw9KDTvEAXqf9{ctESC|U?#%9A#96r zm#0$-ax_ZIPH4J?DmfMwSGNbl5i9dAb8-fqw@*VU7knSk`&>?-uw?@{3fw-;zsP~L zSi&@aaAX@{%Y(%9IUiMSPBf06u%)8K1@SXQ_|US{_AT6XRl&QD2MwdG)GB#u?YCrH z@92xnM7a`{%22IMMTT~s4uw^zED7*k-qHm8vNAm-DYLu22xAsyZ8$j>o9PEcS6XA{ zsyj-i)75ir+!YdgKHH>!I^v|4vsn8v+@@F)9i9pT+WIilztj61tUgRPt1s)+j14Fi zeBzh(k$L5c1~{TY@YXMAX=uyhxyrKOdt{5+HgmC~#z3A zY!`Ppa?XCH9LEtH>wGa*6ZO)i$NOsE$8KG*o978OgB(Ns84q_TN)}+B;6Pq!`t{+4Rt^w(km{iP=OCCX+t0cy%II`N-i}HeEG#4wUj+8A- zHK42!<-32tu=(#qoQxlTwjlG#E9TZO7qGb}uf2V`?>!f9cj&1+)y*;~dG}+mQ+6Vt zZ1Em#Kl7^0zMYt$9&ZUHx%jYUk%!26Q+i9*k(b-yyam}S8qBPSQmgN>(HYK5XsVkjZY9TR7V8jZc39ib`ej#Vodc+gCSiYiFV$U4)~ZZ_ zfG0LhAaf8DYG5lvn6fc1=W;2=77_FDVgg@mUpXsJpbgfnF1EEDrw$bmroX zL+@`a*2je$t^HK+j`Y$N{A_y2l<@w285j_6mg-L_sG*F02K?lTdDU=^=WhK{+rATp zi3+weF$W8$36|C^P z)V6rj{WGxN0FiRPoTZR6!2PUWg|ok(?)`7@f3sy*F8*5_{(th8NWu+?U@!s<->QkGArieEK=f(-Uk)P1kUAD15Z<^xhx26CBR21LNkc^?=XGJ*c(P>LFg9 zZ13sh9!AsDhi=Dg!mdRPxHCh_I2K}(G5GnTQ|Zc9v|~z;o%{mSV(Zkmh$8w9Y&lH# zS-Tr$xUfB4-@j?&Tw(5UqjCH8P$d+N4juSz)A0K&_&wo-=giw>2^tsvu`YkF;6=nH ziy^MU%9UR=dv~I4yW;sQKM=wyd2N5493YYM#lKDxB|>HIPcFEl9w?-6$x#nE@=KO* zzhuc(R?8h%)l{KvGS|_cqTe4C#;N#AZcf?^{dG3|ffs8&$qvIOVr*yGBv+97^4<2P zMC8aXE@o%XLO{suYyUjb#gkrRRC5IcH3V&G*S zes`*hXMbJK$U#ATVtqwV7Ak`ZUUwLMKbN>gT#@}fx8K*J8vmM_;XL0iPiX&WJ)SrS z{K=K^r1^B|uE$9oSZ=V0{`fj>{13G z$vpVQvEA7; zC{L?(fY?j5rR#tIm$i%jx|t=`#6o&FDGo>A^R1q+i}><9Y%k@lL@!782w!Ko*OXJ5 ziaMC0i17(P`Q1X2xGw*LqkDFZq1s(zDC%DVps77Xz6d9tfptxe7{ANOX=f!rG6L#8 zh_w1x_!g5}lvg_dBAr6G-4CJidAjliVDHQ_%4Zi~iidtR$ZyEtt0R9V&|I^#4)zeM zA@*@TG`#%4w`Z9l{Sq(e@dgNLL7nScParIsRgO?b6( z@4Jt=dC6#~8IQR^+muU~6uza8R#;&`l=H=v&qG=3=VT9R#p_8~4}Jr1%%PcsxrRp3 ze&>{d8loj+0FTSFvIr@YDd}8SzHZ1upid}x!Vn}9MIl8+Z3F=wH|*%|b@L*<){k6! zmR|obx2eqfDkl(Un&1;12=md0R;79-yDe&DO1!x+n6536_rBby7wwT^{3lYs5c@hW zjLqe*fYgDz+=TgRZvr>XviA1S& zciqHI?;MuA!<7ehGeLD!k7?dyRa}4q-dbZ00f$y1=39HCZwt^YlBW-zi*hZy#ztNy zOIk`A6N>EeJ+&}Qgx`eXmhg>ZQuRw<YlSn+Sk)E!^2hO{OuGj? zV@uXKj_VbwrSnP1L1Q#6fKFNmYFOgZr`_P*Ut8j}8z$3MDv!VDm{IGx4POZJ2W8Dt z#U`)>#B3O`09mK=`E-=#l~^zKGbsl-alWN`ZYI7dp%haO)o&*N5Yb`kWjGh!JePEV zhdq{cbC(Vmkm6n3L6a$25ER5pnlXwnJwG6}<|IR+P$pnRA4g0!bnLGkuJsykKFU?TBp5L5joQ3l)#>}9sh8Sc=syI>T|th9=oF^B6si%vK)O->X+A$U z@?t2|6h|_EB&Lz@V28r{(gS==Byu)(IIaA&rlx~Tw<*flIVYhyjb?%fOK0T9rga#A zQaS?gWUry|3R!C$ULoLz1djb2y#I3Qub0ixFeB(3_dUK|FL8Mt`|k2v#59}L&~lT& zh&tEac7F;4fNXD>j}5dNIWhVBW$kw^D}yUZni1U?vuRoxAg2ip(l}<4+eW;*#1TZlxz6kVKIGmz6>a)lW)(Fu&J9yf)7`O`6XrxfS$*P$rkq zk`O@_N$au%P+If4XL~bFjNLp2tiz7QC%=C?IXYlF*|2Iq?@!zM$z`21Vt1$MiZmoz zH=+388^$VmZ4sYd=Z{0Qu-n~Wly#@kgGnBm8d;5HB9lx@+t@J0n;+yQPA86?3@r6F zZIkDOrq(@;zpoE>VYN(-*vCXw|0wv^X3|k0)BrxcZ*-&Vda|7e^r)-;U780GSX5;m zzPa#SCqlIHv7>K5YsMwT*`HkOEeip0iz^Q1TOn3#tVmr9p>&3W*|h+Gw5{$I3gwP0 z@1K#TXIEBDY;T{7mzcRV7LBhWyANfF4rqQ4Ue7*PH>)IPw#=%~TJs_`luNqW1+fN1 zqB7tk`Gm15Zek`o&dd!ffQUna_-ttixJ6sJLt2q*wr;guTB7HpfZ-j}}2el+-OWp{`3r9}14vj*4AbFTU*X~yhEv&|6vh3zg%oknv- z71+w&A;pEO1;5rm^VN9Mw~_6yB_+2#$D;UGZ&z5|AWePs&HZG>1Zw>_Sv%#~rPhhQ z+6MuP^tDVHq$S(*foH{5k@-j+YT#^WScmX0X)>FT&~k!3td0E1#haI&q9J5oVom+3 zg(LV@rOs9Oj{y8`iXSjPqId9_WuuO2U{^7|gr?akHZ2$Th~7eTk-{T4P(DG=$$c)P zS~+Z`>j<;_chf^C3+|@a?q_r?UZfaI)SZcnTGMB(1>vld>;nYCSJwpIBy+A74|;>_ zJB$^BX|zCS070v0#}9ertR@?_6~IJ*(v^fJl|`U8%mRr0;@T)-PFswL=m_9%k3dTgszC zmx1s?>M>53Kr<^FbjK|GRmM&3$YzL%PO)sR3q`}Kmlfipua3|}#9CaQ;y(tDS+B7Z zMKJxDVrjM+e7?~YG#|6}+pi5D&Chk5YFygzVlY3E>nZv93Fkis^nsjxk!Ups{$}jj z(^x_WsgRmNGzm|{2WT)F2^AY>5B-vk^g9x#E=~gXSN+;Ke(wURQCq$Ok=J>MZ(Xu7 za@!q*$i=eZsT#-?1vfJar!A_2ZF4uPUx$n_bVW!}< zwMd;3STdKK^Vq7?CC?n7g-M*Vtz9e6tdutBiM^1zfjM9SLUc@#d^YC`#w`JXD zVKU?M(_cDM7Vey@+1lwebn=wYNS*1Q3R1b-7|>OD>^_p&UEei>rWsiZ5BCw7d~&hw zuxw-_X=(VD*0jr)5%S% zD7bzR6ct4IKr{Ohoau^?fu^L1lB&y!uvyKVb_1pur#}vE(Zbe;QP#5j1U_kngSYK42lQ zKX9~#Q~bSLbYOXqoS(D%GYz??q-ZhRqj{LrT+Dc|yFP2K-6e0M;@YPKf)`<~`C1_&cu1icA@1%&4Pt#erP-O~ z3=w2Mi&N>dRIW~op=^0JO?B&6STQLvncsz94k5jTE?HZ^xO-jXja+P=lU1{V7X#?? zm8h1AIkqZ8Pgcq`({x&d14U}kOR8G2@-^-vx~;KZ>oHb67HH{2Wsx}9L+(Mi{Xy1D zdR7SIZ2U|S3aCU)>xFMO_eaJLg6^4Z7UYzkPt1SAh_R6!GB2ikbsG_;g@OyCs*Uip3;ny4W57IW%ifC4me2SjY(m0>@s}Q`0lUoCRyAgs&z`-%`-Q}jvg*d zLc4@YvJ>FZ_!5wdCn31ex3tD_2=9=fo};OLPwNP+TTVoq-*(0%l78CK(o#;TQ>-h; z{)+)sx_@}0Y(CJ>vUTUISKUd7ZVC50EDJ=D%;Xe96^vZMUe|F`8Deq8tR1AM9m9`S z7f9u!*S$|6Z`1-Vl;Or`K);qu%5i0?L_ToeFHVsfZe1E#9%CAeQq!+{0ldU{%+Qa zlB97;%Vr(2n^nquMnk}YiJH@k439)W9hqG40QoILN{gIP*ug5-*v(r65`%U_MFes8 zV-iOs_Swnfq$Qd|r0s!xi| zXZ(uCy!`@eVmE4Z$500rGZwhiE^1TMYbba-TZF=3FvZyB9pweK3hyxXAwmV=Ns_lG zQ(l1G@yisJ;cf~Qcpd1_+v*OZrBdG3bf3TRMejkioN{Cu#!9`RxTVnb*~^9^)VKi+ z-$D)>oeyHo8AAa3%Ho2BAL%`A6ctLUb_h6iZDW2nFd77fheW{8y~RUUtP}eXJ;mH@ zYAIee<8a@+wi3|{ITVJN!)H$|@q1mnylvot0eg9S{Atl_FcZ!&T=cNhppKF%968ri zORaKmJ>*~YFz;>Y!wAjvhx=!aNaXJ~h_> zHx$uQSzzLonU;Kti{I`Xojs48o|QSdIU_Uf)1`&VkWH_E788l{<~{lJo)NhsQe-Ie zMUstMAkF_%>2!z&9Wz-Pf#_Q3uQ02a1r|jpbY1J2mOlQpjd(vj=h%HT*y}9zOlHd$ zP@bzurGQIbxneT=ad_!PA%eF;!q`Q~aRJ@<))<(Dl$?#-`HA?pM{Xs=)-n3I=K4WQ<+GWB3?f|!DsFL#g!cuq~a(R?6)j&%2En>R~Ik<1n%@^$+jtc>$hAa5u(%Fhds^LtsN%Xt!_HRz*GWKA=YqWIh) z72f}cSIr3}qnm9vZ-RM_^N8{gm@>fonLunm2aZyJ8!-E-IGU`=<8ITcR3q8a=%*)| zBfGCzU2B+{o+iBr0PQCIJg>IeRoSX0AN*2gFaJXkz6R8df66~{T3Ok!bM}Grld0jawNNvU zYYl9FhPY>SCQ7pk#G+^@MtlgdtffD3IbN7gN#@NDXE#m0VWp}gIWP#BU^O+``hn)> zUvEwrD?8CFR;oIiCF5);=?BH0PM^v`l!G_|o>u43P}a$_iF@BU4wQx-YTY-wN{@B` zr8Yjvw;7a#bQ%a$` zCa8C2WS7Zcu$)ggn>kD>3RtInAI@FfGK{l^vpX$>XdDO@1qPiav{Y2$?zT=S)=+)x zN=40N|Ga-pxFGYXTtO&J8iCVP`TFLI4&OII=L-IkvQ*U%H>XIYUW*vGo?hhjHDf|C z8{zU&&Bp5!=9{1WsEIV2wKms}QSz~^5J-4Y++`?Qkd~}7hyAKxz%2CTz>7fy%_2^a zmBXoS?Yjnc$YCKf7`Th9;+B`-7J=B+I?8&BDQkPyHEqoVGbG-ed3ytVWGNF=H;a}` zev=sB63-Wf2amYIC{^dlr(9X?AjP8h(p90AMwW2^eu z{ZC50A5Y3{9^kq0*{?oyH$U4a`3*xC$tX;kO$Hid6MQNV8_zF&Oz(zIKfmR1_R*)c zB_vdtN_o+%DfgslwK=HR!BR2MF}EzpkUD?f>mXX&~coHRY z{w$xTNLBWTU{%*1HhDcho^wYJGw0CR;h53hRq zCG?l8_V7K$Up8UFFMVoX-nQ<&?%uwtv%T&G2KjXeL%F!Jq}KALs>c@jfpU)xmyhZ3 zLyZA6giC}$6*UtelG&14#p>YYrWe*)@{9O1cLPcG?IBMY6bb}45LB~zwe4BG&$Gb( z(Dx04=Y}Lru{}sK$GwW9P*Fm#`i{F(Q1sYOLMOs(<2G~6e>B1;@R?iSf`gbF;iO!w zC7hPL^CmSjt)Go?l#l(EH0xVk0@cSGJu6Ad7m4mob4cNK^HDU5#>(2#{J}&bG3bTy zO%Zn86xKBltI5x}{^;afyn&YTIfvfaAVgK~45}7`efgzr%RnayCQ?UlqL5-p73LEI zy`EtKp0i8#v%@t-^#iW#c8|E+!kkCN=FwtwESS%>e z40uf=4c?lWPg2Rk48GD+NQht^I*{Dv5TIvy4ghwU#i3^;)*o=_Q{k+dbzrS3>dNa zCu)MY!Gvv6RZDDz?@%y+bi;E@6_*l;)J1|Hn)*fcw91Sb!n)=)rkl?=NL3`48ZQa_WM4wY^ zL~G5cvecZ9pw3r>SRVPiU}(&G{XS^-*L?2@Gtysf;&}ba-Du5WAD6lj35Q)LvZnU$ zx}4eHNsN5A3;xT;{M%D>*ysOEut#0h-$~sS%GBM7q_NF*15KA>-u!lgoBnoq@|{gG z|C^b3=npRO;QOCVYcUr#tzO6sZ(AK|+dzwwp(XT*nX3x|xi?AxZ@`#kl;qQ9&`dt3 zYB%dOCAJyH42~H6`tSg0%k9>V>hoA zmj^RX1H#Xb8^Y4pJ7{*h{^oTDfx_73y^OM3@|xBzA`NQpNj4}xpBc(}* zUUJE~@z^wR*>eH7a5qGPaC+EDgFFbhFoy0g?FmunB?gc9JTA0Kux&$kZ)!}#;~ zv{a-81ej=kE#G$b%;Ya1M5zH!~i{`x4MTUF;VKN z?!K)-(xYRqp4_oEQ+49W>EfiM@=hf;^p;7b92tWZ#?+*Bz*)p0+)u8+0WYsW)X`L9 z()8dIIE@tF%wvI^bf?k$$NjaGiL6mGyj__u5Y=|&Lvv2QgW8kTiyz(fZ#u8EO}45m zOj}aMQHpqmP*X}VSZx-YVN>dP7)?r_(@bk8B=k<;Ep2D3dt2cXyV=}j&oe@M$R~;j z52kG|J@}+DMG(JZAXuXFw5*6QwUO`cDnX){OCQQfFkQ+YgGGJwoIOUJR4U zr{>3NYPlQR^wok0lBnCVK^U-m4YKRdUM}Dt|H*NwywztBs$1!wU4Q@E6r|RaYyUOo zBj=#aDl2PeI08aA$`RwEj(i``;`W?9cA}f-+^KDydeRO1yGG+N_p=f*JBUOq<~oaN zjlwgt=q;oPy8-@b9OcmDb3^FOIkk8~P(Rd1l^&fui5fi3>n-LUHmM{{yQgnCe+N<6aTN3}qcZD=AXErKrf7i6! zB(=J?a4{iXZCmzF#JEp-(A#GukXd?hgC@9Ok9iv1DHv!(n~nq1IT?>q=e=Fj>#uxI ztlM1waH^ou3yld)Uw}GkFm9*V_`1uOME<&e-=<>+MXFpxBp6IU_|*>`G3|ElWsb!2 zYY0*k*2IAs(&f*E?D(avi5Sm@$N^J67P8JwRDSB#S@(1I{FL4s%LBIqqnG>}Cc&sP zl85xhjksOs32 z!B3pk7MB|yQ{(#|X~r+5rm@aTwRu>HE5F)IeK>LWyX{*%gMd9f_0DOgMu8O7w-}H| z-OGR#@^)cLS#BrmpP5YJ9b*Qdb+;$M7Nbo72qrV_%mZSU5cdY= zOPFP`s+ClgtgGX!TLr5irorT?s3M6ZsCA_=8Btxz#6~V%{!CXb`S$hxhf~3Mn{?Sd zb}fG7^B1z`2ruHvhrp2oYIyq6k%rt1~fwe4l#1wDSxlfz#MctR%y4!Rh z73CdplVTX#Wso25zR;liIW)r0_8QVCjhh6a20Y zXe6?WV@|5R(#-YEb&6ZgE_}XxWvjG$r)ysSt`WB)dsTSF9HKw9wg-gNq_yy38#L}% z6YvW4`xhfMyl?EvW9l+IL1b@R$7--|C$zJA=VY!g?gZUaRreUGRd2-91MR$oHFON3 zu!D!^Vz8~bxsLlLeyuHje3~J)9t=2goSQ{?$9F^|_Jp6eQa^8${)ts>e8&F%p+~yk zw0_WG(H!pvpKnOMp4r94;=o{5w9a6j;zj0TX|_sXQDk>(-y7*gl}-mhMpOhvP7kU4 zoV2szs?3YtH{Xz(hOj2cwZEaGQE1aLWOik+e_upY=az!ABj$IzYA+T@zNma86%{rev4dx*7(2-$u%-w;+;mzF6`!?NkfA`70FJl18FtV+JUV$-xacb{RmKO`#9dqIg3LUVpv0#-XASD zp{}YADsOQ7fOY~S%vu+a949NAuIb8(ccfPlg-xd(xP-GpAX58r@3npj!u6>Tf91Gh z*9|&b+hCIJVJc`(Cp#2CQ3@$pK&?#5PSz&!>R{_!tt} za@+C`H(or|)w*LHqvPC}*kW*R%n93{iF!Hyo5D{j^!lQ#3;VLleARVg4SEMzrTE<5 z8WF_6bz6m}4r1|pr;Qd)#Z;vCU$OF@R~lLXeY;nHRS@~8$YRitRsN4BASeHgU)4Wn;s5;^ z%afHJ>ZXLatQ2(2Y(L(GweIjdp%h9l68lRE3f72@$y}3x#z)PV>EbZXV0v8 z$}wN#cb^D5O@*6_(~@%E*&gB97V3N(!7-^|mT_UacnBzEvtNP|A{hNDy(+ zz>5PmEwuxf!S9-vey>>Y(XH7*BAAfc7&}RRy&&cGsgf&)Go}5kTWO=!c^y2#ch{cFq z#r2Gdl!BunlCJhl_jTv+$ZA=_RVCkXtD?oj#oH2J5Tg?u$uz@@AeU0uc>5S~dSzE= z#c)!I#OMT@mgXIl?4f-oH6}Qyg3y|Xh3QCIt7D(#e%+n&Oufrnku1w1>$Z#LE9i+@ z4PiOla%y}LKL{PeeSwFrUT*q+5bUR=!3(Sst*p7J~aVA==n-S;$o)*h-0FS2FxEAF^1T6zcQJSCqhg@!;vVOf5G28Cm#nh4W@T<*ZT_o|#wG8* zg9fMX4BuOrbqwxLn>Q6y-Gfi}PepQxpXFqZw@*GD+bkZ^?NY4S*EOV?3uYEx>z>Kg zDbDQjnq(6pQSfjwZI(BG^1GRM8;6P$n^K6sp3B{IKk9YT0a!9cSGXSSOY6Z2n-it9 zfvEQrZ<2Kths^SKiU-}cmKHQ<@Ns9t&ZsIeL>&GcrY!OO!;Qb_2Ri@so$IID_;Q2t zF@ACPK#*`CM!$Dtw^SXy%_A@`!Kye32! z`oJ*1A2x&pj>dT>nuWzDh6DxW5@CKTzbSyv-wE`1-Pw5dLl(H|{IAOWJ*JknB0vv| z-shRP78VyXIIbW^93}FC*yuOZ^v2a<9295+qP9m`Sc>A1Hk;3u`XwAO&ELVGlm@X`%ZumL=`q0;?)&K;GMn$?5zdbLQ~+mTdqS zL&TPLgr&7$AsMD+{_fsmw?lRnHyF(KR8?)nTDUt;cq8q2NLFG{*oW-C{u=vpuiR~{ z2{9>~_1{_>$pn>n4MyX1bYkA}K&JXy1h&q5#^Ua3Hd^s|@{9T{dw>O5Ja+Fm9Y|+k z!Upp{KX`V{eyGLPG(bh!G8S!VmBsCTQ5xe!ABqVx=ITDBar{$IZt5eMOcl~h3fiQ( zyMJsJF=KUBUo%`a>3LIv$Lpn?7PsRM5KW~=0;RF6P(0H>67?iIZDL8JaF@h?|)5Zx^aKY3pS zHYZXN)$>^7%DT0*0hJep%Y{S$Bg35ZIiRx530Opl6E4Nw`B}+VYX|a{nY@E=i=;w2 ztRVQhgQ^k+;A<)}T0=ihqgUO$H>`9-xv@3Ylk%(^GF~(w_o!4tqNa&1?68u9TimUC zWoN0l$FfIN%Ka0z8fk?oad}NfGfyFImeM}eFhO|XBUgh2xcl>_E_j+r>jZlr8iZQF zv?5@@zS(|K4N7+SxxQwS30d*B(+(!PG%77Km5`EkNc1emnG+Sm@miL1*_I^w)+})K<9mQ-h57^9KNx;i14BR%p7a%J#M^p zO^zlI%2?f1tf;Xc3nO0oZb<*GL&ww_e3J&bt`lIwW>5EK`0K=$b$j`^BpG?vX_V$2 zdTQpbuhgf(4WuvyZT-yN$XD)`mvpJbbNYUK4hH87QeuwM4ZJgu>+(0|^%xUCmRALJ z$p8)t6?-qH=?2Rkd@X@S)hD~Yg2|P~x40%Qi2i%8OS}Rp0jQ(TP0~!(^;Ua)%BA4b@wT$(}^vhwpC-S7n z>q54Owqezm&sI~Qq>u==6gnr#COT0&87U;zNDjQUE`>Qh{N#AC&*N4%%mHcIEx`rj zoQ`Dpkhmxo2tZ~eSJti^9{O^JJAd3x(Z*ZzkfWiK%G1CH(g2GW1`AH$(yc*aZ*l_! zZKY}nW??vrg5A>8?jET3%r3fOZd_2Nx_era9`*di1GuHxzNou@`NeRs7d`K+)ed>+ zsw0+hQlm(TpvCo z{VMuSMBkIS6JFnb&M_X95BoWxSnHE_->&b~v4DW5?PYi6uNy2;7{ywM8N7AC?bGVI z_6G`n-^=owgQ+{1DVD6pnS~fHRVCYx4#+ZvJtx1cKo$A}J<;>C?tGUh)C{FLKMmop z|4VhN#VK`y!Nzr+Et;CqhVItBD6aEb6_-qsvRKhe(S#cA&wx9`-NGc)>4&wU*yPhG zDwKsVyKyg#hCtMSj|h1MUv@ff$Me|M-jO&Q2v?<&W~c3LD>lgD1|k8HX=gb{aVgqJ z*d4iVsC#m;Th6n(BnMX`os+ziDLxIzX*;lpNo~#cut>*+^?Tlem{`rPbbCP7M)41R zIcGU}J53X3<=ahz;*35b4005{|JRQFCp_PxvGEU@K0e`|e3iM8aX-*AB~umILc|#f z&ofky$92RVxokXsq^G=TWG*NX@z)apst~nbzFZ3YqudXF7x>a%f49Tm{o(KDV($Ok z@gW+sL1^e@JA;(#g_+(LXnJGFDzLAr%}MaVE$|;3d77qRcG_e&hVa&1@qW#CP2MrJ zC@p;7n|J3;8(91Pa>7KBzGe8!C6hnOegAiXFYWbTXa^AOTPyM;9PXWgV!aHB-2}cW z-!j>9_3MI=tM%V3NGvb-+AJyZ8hV1Fa*4G_{;b_NqSvdKbqR?AX}3p(?%g8bL=Lwm zmm3Q|_3>v{n1%S9CQufuN4Fb+0q+7&%ccR{Y-ScV!rgu;QZEr_peeo4jdVXf4>aX6 z!LnR87q<4H!y^LWg%_qGwK)u|fkwf>RoK?jGGR zC-uD=>5XXyy=kp|yFRl&6(Ltx=_dnH%NqAOPSPCv@9DE9sEcfK``qn1P=v$o+XMMw z8SjCFqfWWQ{3T3T`J=^jM@gg*Mi+@x;cbE%)MugYukJSH4FONjRaNyLI;v=Q`q|W+ zCxw#^Ht-}h*-lmAbUt)_++I{3KpooL^*kV&F-FhIeb+Glf`_62CJZPnsX%L~N~bz5 z^$w5)q=So=5B&1DV6a`%x*UvO5MeUeL`+P{qxr8?{$(lg5Akb{`k!bZF5r{`Frl%e zOWU;}l78ic4*^%B8gNWB-Z&Q;Ikco^fe?|RVn7$ZXJ2Q#l0AY%V8#8GID7KD({`VKg($+ z-f@i_+gFgy(V=ev6)V^Nn~Ss?o*}U-HnoJ-0L^F6zynLbKOG+K;IDr3$IH)mjNU1& z+sWLhi~x|ktZ4P2kDXjQ*oRHU0rBy}L4JkK(yEVl3Ve1%HB9QJ=^Lg7Qn`G3(Tm{( zK$D0Y@O8{O;xpOwt0mjLpZ1oPJ^pa%@___#Ei1*EwVOt) zmQ0dK9|yo*v0bm?A8K;V?yCZ9lTR(i!pJAxy8^7Jf`?Wi*fU9Z4u>xzMfaPnf;@c9 zep5&qdHAA{>U!P$n)mvhhTyP34Q_u1!pRF=#(ErocJGgJ;+>w4zfwWtIWldCvPffAqG_SvFcgW`VPm&${pd9Q~4tn%$DccrFb8n zL6k-0I-!lY5Y}CT`>yr4?IpvzNQp{%kn~p&{W7x3mmO4j?Jw>5-*xb{i!a4Y#HkAy z{!L*rKn0pfs&O1W(MI(dL-w;P3WmR+L=wKFq-XApt3~mVymkti6Xiogv7+fE7qs~Q z!fkOoehU=<7{1|RWXEJo$ojdhWJZR+thlip@=gZ!0^=UC9^Fqivz-f~Foz!i;*;fs zl8alvq*}62y`mr+88Av6t|ip*e{U(vcn6(Vxl2_|w*+|l8>S#2_?;h-glm3(Fk=2l zz4Etkub0u=oXkR`GB|6va##wL`4-(HMyX|4mo(k#E(t(_jT;&tMdVD z@q^Wui=pYNe#txJxZwhOusWd3A^VL#lWS?=mwu?R4xT{Qix_k3z51oc-@Y10bT`$t zpB(Oe8UOoU4yPw*cEe)@1=B{+TfC2<0X37)vO3l-nQ;%|7MCQ?)`yO!zy#jq zPC(mc$+TUc($3Flus+iDLsn)86^^CKRxmZFiLeLETjh56u`H?t*94SWlp;}(vY^ro z2ogd_S&5lB@v|lIo&>3?au9A1I;8RWhoF#&IQ|&KKN2W1l8hP6eHT@IHI2p-CNf5phc+%093o}+i_>-iT3jH38(82eL#U=Do| zJc^>z{W++QJJJIVcOVG}p23g?adD=^&ZI2_6ifB^rLRfOi|so8^+|EfX>1sN6rWR> ztZ_TCXhzsRCLhy#!e;c60^!>JSZWM0}&&Q6t-*u(iXFJ?Wnk^`wsunAI$k)I*kKXH66 zbgy|C4cfjU@)>)JoIdgwFV8>!`!45re|qt~E|zcf{<$2LTTuU)xSbLii34jM%lp1O zG6i}vIVU5@t^&D_5g)d`#a`$FNLLzQ4-4SRuVD=zqMc3}m zH+?gg$O&}sa58OXhf43NPT3VGrt{I%flZIxaN^OGhi0k-Wu%|a&&A^Q9k*4~`GB>)GV7_>}*=4w|~G3~6=Lh~?a!sYO*A(mqq0*{6;jKSdPq)^%+c zBChra(2QPxl_HQbK_|u9X@Bj>payN3A+H*I8#|e@g^}pZ*%dZzF8tF!4YpP7ToPvOy!bj zDl(i^9bZ&;>G9`7_h%{Zr`i|8pYEPIXEoDk*E`n!&?3C?Sb)u;F|-i}pPgkT3AI*# z>8sLA`RQQ`$vC*yesDB!Fd1VLpxd^N<>ymbi|-v}uev)5chk6&tmIs;I16=wduufy z_ez~NQ|pJ~DkqZIqe~iyaTbz#F~zvtAk`qk?$8LRG#Zm<2y zMi-W@bgk8Vj&nh&drlHVcHnsgm|}=Mg;X(H9aM{{#wKG9F;Q(jD}iO}2`^oP?x)+o!xok>gJ3S%ULYA@`B+=@f?zi}`-(g|j9+=KLX3aH zriqay=WdIcZg3UG_Z>&91ju!5XCzp9ibOqO5MU@Wpc3XA#R2D6P3I+>|ULSSKSfi0oI}Q9l1N-U!=G>*f~Uv zCRp0a@B!ZpiZtY;TNfO-u)sR zQ;Do93+srwa_2XN#5$;L53(CAY4Pwf8)s0$*R(3pFG`=^Y6)8klFvM5KClgP95jak~un>3I3)>(P$lVevcy}_P2 z74PKS04KRKIEzhzbI*?t;0HgQRv*$xjt`I0lE5rFOkX=1h8!Gy9GQxmt6?Q&#uSxif1CTp|JLqhYEOWN{B|tue4WukqKTY) z!&$(9%=FL^VILKy)VF_||IrcMl=Q2?sKcvCxLvnoT5zt>s|MvTb|0T3pT3AT=oODwu3a1e^V6xOb8E7xDiw-Fw}<5P0>VVcu8}nB+h6I- z6)ayK*e)x0mktGkgM({%)C0Fbc%AzG55zQS2fHKx19;$+M$>Dg)}Ev3p?(y!lM|$M zSBE+?`&NnFO*R%U4o`+w9iW5>eHoW*Tu#-W(^PM~LPGNH`qu;4zFL6PL=qbt?*?6{ zs?!ZZ%pdhwjV|m5(Uk+)mdDU&3#@o7_fgorWy@#C=IK79FX&3q^XYSLPmQ)UoXwiC zB>2_ngI_am&V*}p^PSdI4Rg;GiEZ9SS6y4<#_-acd$C?XJQc7M?$dH9Dg0UQyqiz& z9?Z#ptGzk~*ht7qQO>x33)1zefb^G9a529n`?cHLPro=eP?HRNCdD3R-ZLy_KS_u= zP}}P1Hrg>?vhstbrgMcntcQn<_Pl*xU^Im=&Jh!;)o?M_R-6y~WbP-p>-O~F8M%lP z>|JU{002a}S-Bi&qApqWyfn9orSYpGxG7>>rC_1=LTyNpli-Ggd6k(RBKf z^;Ea8_m=32(qi5P+&(6^gjtf?9`%ZGzl3Jq+>M8q5^+*GkN|DUlL;!&&e9_2+sHvlB&DSSfy18I|0H5@WB`Eaj>bdT1yci z1+Os*MOnbv5Si3!t-%A0MqhPCu*@eCR>j zu8lw4y)eGFYdVnTSQG|;W@6(s(t&gg_4&YxY( z1!7?^Y#m{D6iS&6a$@-&h>P~p$#*fCVihwk;deSP)V4&L=INSLuzQp}r3Ie(iojXl z66^0?obxfTV=eBwrCwZbZSIiBR}{^+=w^n#_N26FB-&(OM7HtTUHsbomZ8$irmuDf zCr`x_cWdvsp8lZT7~NbJDeCBFv1;&heS@Sk?`n)1&y^m#Kcf0QN6+571h&oaBxoGL zZ=S_qGNs7=O7~()U@wgluo-i^doimpnFm@53rXwd>tvCx;uRFO<$Ybj>+pcfK{2nd zYkbI&wYPHR!ECB6KxG+f<9zG@lJs{(fi;r(ew*n37_iK8yuu&?0E8F z%j--IvZque?ucBbM`PviFFP*=H;-=XlM-OM#^+n|RO3_$^4MkX48DpiSX+p9S^}OQ z^Y5koOP>C<_y3Jy|D8N|!{>L=im`&?)xSJ6|2X)Mod*AWS@igq4O+G}f?EGf*!vB^_^AiTNIVAFrUm)<0A#AIX(^~qn z?jJw@2dX#MnLOqcQ}q98N9`XY>tDVX+q^m=`!r~E=k~&`1n@lGxQ5*SE-zJY@=b(^ zc=WSI*m$hM#mR-sDVFawwcNyw!~Y*$HZv+gL>i{x4hcJ<$azzRwxP_q$Ystm<4^35 zdw$;)`(JE?VXi0Q^tEZ<6^tg~M=I~$UP`aA`IrcP$YOa1cNln+802yKkoYCrG{%SN zW`6g*=^3rbv49~it`F|?C|vulB=VVu2Gy8iI4TW-0hbDdMs1ViosFx7#7IGV9ICZP zhpI9;udyrX`(MGVpx8x{`*~{#14p>(#`uzR6H=9!Q`Gm}1s1VA?1kaw zx?g0Ac;$Mgc~JLzbkjCsY9fV(UM>cTt)>PptAKltfr=4$vp zs_IV1)vSiV!Eys1Da#^qAYlCSviJCS>QD1h`wy|*8Aa|eb+C83QLZ*+0t&%v2*a^X zGfo2Qx0RUWDFgg`#OvoH5{66#n(|=5;WvfwDteu1bWyG#V+LAtxB6zN#%7Xf90agI{KGwBHDv}8t^>}<_VI<;SXM(|C4l-Ab19G>NoXE0Hxmueuok0sS9 zC1+IWxu=;yL#JNdk388yayi)~hmAJ`6yt}<3=SazXD@Uz5*l{{A6Ha4n9ChgkN=Qh zaV<0#Tm@r$oj=-_H(@Osa~&?MgbU@++t;DHiVIYFoL4a#s++eAiN__PvP_xKU*B(* zl;WFw-W#|-{`sC*kl(0W-L$KDv?k+a#jpPvv+cjPnsj7{oRupVN@n2Ok#lfr+})f_ z`&w&SP0J$FuYI!zEwV$-xu4y41H7_de%AGy?}Plt9mClxdiK3>XPiaOu+itg&s> z8j@dd?HAH<$Pmy?{CN08p~Hv6>dilYb;v$_@$8{HQ#&uGe7o?&zbXBrGBNQOY{SLv z6urq;5y0=i`9Hbx>5y1DeUv}*DY2RK{l5uDbkgFn+yVRRR|<^3%%}f6_z!swCjyaE zX7TtXxex0fgO-mA#PwxsKJP1=E8xpdHPNs0_Iztpv`Ab!YkfieqSgRV*fQ<^sV2Yb zLC;96fs?~Er1jym4GH^wm}d9W9K=HZ17_G(b~#7=tsph~)#1WkO9MAw*UbDa;?}1~ zNUYod7S=~}+Yn}%8L;y-&)xFyo?x~djvN05rIV;^v_J=`F_kQ5r@__^3`QsgyenN{ z(z&EKUSMH@(7hB)U^SYqA>q{#1CDvmeUNha4OY2vhDk9@srDhWUq+kX6Y9xh6sPq(qomis!*&MAjZI3* z%a9*+nx^#i!2w}gCZaUAMj)sRGI`u$C|_;S;%2+sPIki>mdYR~sAI9XVOe-VoE%#U7j;HahmqW|+RnEZ-$Ea5-R|tMx0qSaEV4Jy z;7$J^ zfG(mYojcc8$=t4+my*1?%>|L>N_D0r<3P%~-LZWa)FOhP#9wiEw>6fw>71UNnl~5a zmjUH3gZ#Ql}gOWhjIPJU+ zl`y5zbRc{H%a3DDGJ(EzREg9ayJ#MlUPbdLq(tl?b`d=96*J2PBP35E)U zdgyc8>~bU>PC5tXKUr1q?QX|=3lK;=926}HCpX}{ltX;Q;XCoddUMQIjQEtGBS;ft z&7GpR)Vs06+}K;>dCfJxMc};+hNzT_ z`CER#>*RtpmBW*3ax-nj8WX|3HKBv}iZaJ(VwSaeS<0@eOn<7FEgZM|&{|ve;bz>g z`wDN_;iuU^2Mg*+xICx<(b?x&T=wM>u!)k|aPc#x$7f`WeDrmXda(VFk4p-gmswsD zRV;va`Xt*G#0<6Qq_ENgFpu-7@eSt-+jS(W(omjaJvcklSPoh9uQ1Q2wu z(JL^Z0M0=PFvJC_{B;oAlY9JD2g@gAC_`9lpAN86ej> zaUbzOW*`O7G;ov`1*Fscq-KfY=di0nKI#;1lDBF1)WN|8P#_{!q}5~mvNhW{gV*UE zY8!v}sqPP^$8DH_@$jE`mWQ)?ESed&1AxGbjx`vEiLRF5f|Tsl;5}Oj%oL{kCW|vm z!FY@2B!QXWmOL+qazjOch(LOC}!v@R+SHLrCV zSZwEIE!2AWvO&XbdtyK_dDr7K$JG&Nq(yQpA;1ex7-D5dR4}#bROVX=273w<5>f!3 zzTXDf=jWe41?+|;Gbh>{7S7`$pwmw5p$YVG=|5`9;8bGn9Ct zcH@?Vqh(K+^vk@`jQ1>4^FZUJK&;O*D27Ab)#0>cwGEZ|;>5S+Y3>cpKd?&@I1hP- zlxYy2%E(QO_RW17^JX>0;Z6MHWC_ws9@ChKp~Be_z*$H{dRX=*lV2QtFv@wtU|tFZ zu5|lTDGmto&Kre5)~_P2mBx#OFso8D(8_zTHF3_hYyD1_2Ry(vA-Sv6z8hnoNW`39 z&RQ*ucek|98MkGn;Io7XzeU553pZW$5CYY-khJE+UQ%vz?p1_>5=K)qK`|$itXMoj z_Hwtt@#G5uo-t|Jh|64;?;k%{($o_Veyy>y)}8^n85;OLBIP^wo3I&H?IoqT_E9+^ zi@?vEy)0memBfo#$^}(?RHf)T-U|f-)^^!hLedTWelk(w z$m6~NfrLoop_(8U;*5AuHzi|xH$Q17HS`r35^2SWsSU!lDb?biUWiZ3D(`QiGMz0fw+j zVMZUgW4<>%%~R-E;Lb!h7x`SD8YL66$}PClfj;cf62ERa&1eJn>A@q`=?-UCu@49b zt=JHrN3zUrH9ZJpg)952Jg-87r2LK1)Z|D5iTap@ z8nZ6bawVtG+5@dov1EGO#VCGT)E>`euCTS8!D0v_E9Iv77-g5DP~+Vddmqooc#KDh z0WIM@Y`LQQeQpe7{_6ztNf^mE@} ztHY6Q;#vBTKhKawqB#+qhQir_DGyk*1fe49d{EYAblBi_`$sW;&$UVh<)=z_4X-8TcefLPqu3Wq% z4=t@cb(v7F7s&hQ$CA3Vl2`rxSyBd zGwMIlFj)r#7kGA8$CQP4zB$>kpUUM*PcND^d7)$?FH>81Yh^jA6N(5ALaZ>iQ$jcJ zdO+j#4?l#?4RpBn6y%WPPI#HEG`?QQDTMhVG2$@}SC~d*VQ=H%yE82dq-~F@<17L6 z8P6iDC!s>AbF_%kJYm)pGE>>_uJEr&v%fBY|Dz+l2Z?%|lAB(jE(gFh0Ys&8tmDCtlvNTZ|M;s}}_Fy-4Jj(LE7!?8?x!bv=tT-m?IEsYun6#Yl0T&30|M zveSfC6@I%{|K2jof(M+z2wN+W?TqBM${?y-vaeg^vPV4g-S(pEM;skvWts2v!~^KD zV5p=3u91WXEhW9RD<04=zIRf55_Lj#t4n~~;x9b!bV=ye-q;rt_u&~lRL(vQ4W672 zzM&MOO&XvZ45iIZ%NCAxcL(gg=?AhD3X4OBGg|r0WpYAm+UKjtKQMq_|JVH~rHPk9^6J?IZoKh&X?`YE%AnbNjL9cTULfqL;wqKX-W9%V=#N^iGiKv=+>yHL-uNW$e3wCZ64)DUt zSszc0zH?D+I@p9DmKqCy*ViTpJ3Ph8l@^?ci)yQvYks)yWv$_oWll4v3FTbjGWWh` zP;1To-niQU+QosNW{xkOtO>pZ$y)Ly*TTjyv+s;Y%sI3h-$YK<1nd8%@cMaitzK*L z>_YrqMBi@;ClCLo@apibcZy`y6m!;fb835!8J>_*Wi(UkINn=h&+c{6y&Z5nMNJkM z+hOoSBG0+Sj4f^*FhIHwJI;z;1e}ph)q=}kH1r>46r|Cu0z1bo9yVcd9I9qE7`Vb6 zD6TIFb3~p_Ke|co6BRjeQ)sWzSZZbfdLO5xilKcLEi-P6-QB(I(j{$yN@=pVNtYSr zC>_W65?DeEVRrWT?NiZ@Ttg6r+Y|S)9e|l-FykR+P>nF=LEq%M4GebUwS}pv^{yX3 z^aAZ_CR3xbUE-hF)%29-3zT`xhn!Wa??#s+=H>1A4f?u$%5ng=sV#|%jCvDRXBfjT z^Pbso?O%zQ|7lOjBxf=-5DAKG&Ez&p}HN*@!sPGhBaN=FvQnB7+@928L=6^wC(PuMLS;f6zv_eiitk(sK=tU2Sgz-DxtR1MO9qhKzLF`ZGffc zrLHUsyqBEdP_0(AzRt4=^V$Nxjj*$BO`b+TXFhjC;dJqEP8I|rgG8pJTVjzkC_+|E zCHPxHk~_*MsvKXAQ;Nwzlw2fAb-#&r?QFg4G+M zbEMIS;ZcO!6?n8(mNY2V-+G3?nidXKBf7Qv3@L(+KnY{6!d09@ssjT`OZ=-#g=MXFp??RikT&(w4J{^O|AU~N%fFQ96Znl<9Tu| zKutK;{V%kOKmSO%r_&!gkm5(7a%S|uRn54M;P(ALw$uNF3mg8S_;J3+T0C?4&}YEL ze`f?a*!`ozH&SdEP9z#0t)UV~KWhv2w!2-c>%VGodr%JX2=we|mlHi83K@T7{y2;M zadY(lzb*&2Q@E%~P=5yBNPO~VTK-znyv;{>(B7~9K@W%g$w=AoPv1IAPuJaoGv%&j zK?c5QGfuUzWs}7Iy2)G|VV>o!D}fnlq+~f@@xW)AU75b!Wix03Mi13S17*kgG#U6w z@bdA78Fb=1rq&dem}cd(0v2`p36QfkVH=84YAFGBrst0v6vg9zYVjvz1Db4FeEJBC zStb_so5H>A8hf3(a!ixYl2`#@sz9Lg(@Y*#<#1z550PVrlGTGIUXyW(jF zEkL^+SVnyipH`5G;z(;c0|a}C7=bAZ6X8|b?Vb+B;q~%jV@tuia}7zoqopZ_ER)? zd$Z=;^uq^c&CR{>Xk=EteMw%y4ix|03+UGHSYAgIyt5^~*6mU=gZS%WZ-Ny{w!b)C zCEd2rrz5GYLlVZyHhSwsdo@c;+EQ37W@z<)>rpt#Jj&h}w^yeSf;Y251|Ts&@Dhvu zpw|ZW@Pd*r^44>K$U7yCez>re)SQ)=*-|br3W6e$vnR%ShjU+`UW`mZ9lYG&sMS$2 zT4G3&V3RK`7&or_J?S_!f(cEtE8j-wD}YL!r@1^}aw2iA190F98XgY1a8Ji(@tHPv18>}WIvfjbMl*vZ8#VUxU`3vl|=>wJ=1n_Z{Ovh$!b~q zrN}q;^0^~ZcCIDp6mP*g7ima-mrBU{WO?N=;1qF$DVQ9KSTy|r~K?j@$O;?A}f zW_fQcbU+m$jd|GGGS*A!=p&9i9`mS)2mN#fU()8W^MD(hj`pxsh4QM-W_Ck3r5+S)r>Lb zXsoxl;RnhXy=Ge1%e3-Un)as&BG$IH_d<#|{T`wdeB6R=TcR99qJuOv{Z1JY( z8DDHy*D=r_J7%=pIPa$tU#{CKzMz6D&T#NJajU1+`<89F43yX2+TF?;gW`r%!`>cN zbTP_v?3XmY4*ubU&uBE%n(HI%l_Xzj<+voy^@bDiZfE9khxaRQjukiZ*%|)ltF18- zr>Z`}vuo5@NM?$(7z4_US&tL2FOT{S6~Fz= z`oGwF@3^M2bq$!Ak(m+O0HR3IF`-B(N(l-IjtZfKVjw#}LQ$HMP$U#b5Kw2N1OWpk z2nc9sDFjR)ATWT1-V{he2T^J$D$+syVmrr~Gw0m7_xyg}cYohK`=6|xoxQU5D(_l* zz0dnRCj~Y~hZp!Jo>gVZZ#(*p2fliEcXdL*_lA52>uq$4DeA0SV@Ve3*Pg#%XH{%e zTHdTdKJ|0pjqUBW!%t2WP;(=JNMalkcPo0Kg$PBLvYi((3(APPVHupsFI+ws(b3i1 zp%9Xz!`W<#u2)|rCL33-GT9HSt?tyw!gaXN1AvX0B*k6I*K{|(QjQrawbRdefDw9L z;#q~iLaV`sZ0vqFa;D`(lm~fMqbTKPER`k9Xb}Ahzrz7(TM^sb!ZmO4e- z))i-%3$B@|obuKoU4LZF2pJD>*x^_a>{AguJ+t#Wzr5{-B;Sd|1IOUTiXJ)#8X+L7 zE^Yg3M)(V+wd)Nfr7wCN9-X@;<7h4B22oKs^YZ3MDCFEcK#VSMa#iQajLrxHNz>0C zx)=)2biDT~^2S{gOAL*#Z~8(tQCCxoL5$GwbRK-|6%`IL+_+zNmle@O7IXy?u~&v) zKAD1=4XO}Fb@iP-7i90e&DbO_K3oxgmGv4*Gl3uH1=ur_Rd1DI6+0mVKD{~p`(aes z%hty0nq?U=Af~ZCqob>P)Fh`rg~@I#qCP#f9qdSc$^rx(!jF-Wd*miV0Eq)2&wEBP%MLFFB8c5m3 z2{rVDdDSaSarYK86Ne}+%51yT_Sy5Myj(MHvbExXDS4X3>}c`*MV=PqDD7TMN(cz3 zr=zeN+e~fQ-U-*Np%=c_|ACAv!o#@Yr?}gLRrvPcAkFNuN2jujemi`rK9oGq) zl$UD}?wGG2_NPcL&0XAd<7WTKz|finUcOjK$$&F%c}(X=&Nt_;KI3w^#ep21jw)$- zI-pTtgv-H0#bbhu*~+A$l^Y#jtLE1Q7Nl)|l%1EnHNZVl$2iF;)PQX;gI97Q^9>9p zW*;4LPFCLmA=wp}?t*W(%p+uh_ke$NcgP^K^OC~fW+jSJ2cNE5x}1pXd?qE?j9l)o z3CKkzT0~DV(Bn>+Fk0XXo98;Y(gP0GTQ(3+bC^hK4CEGDuc@>xfkO;cCpV>^tx#_k z(HGkSGNn31@j6U2q-*W+#liNIE`dn9MvtK1Zd5Wu_i9|9RI?w2sH5a!qTRin_A1Uv zb3@ZTwKX2M`n6PuZ#^sWKiWgP^zSZQ)K8zBzh;#v>*Am->o;&x?DmA}xut{KYRBw* z`#Fe8a!Gd=l*bqtt*bcvq^}cke;ShBxJ4h%aAmn{#u64HojL5XVBy-b0!>ykfA&=m zqVwUsRp=9AkRe&Q^4b*x^ikE|6*G4ekZO4n_D+ELk2#(@RiEB*SLLN1+6x%j4Xsz$ zwDyFhUy-{1%Uw!Vb{O`7Hi)dEl7%;L4KyEVPS=IXMbAVNO5u57+(@M-mCM)IMQ!>< z7+8NpGsByj0Gn=M6nWO%y^6m*%MxH6R3<^@=WDT%KP^nOvzG$M4P!Hw$%5IX41Ljq zF9%#+-kh8Ntz^?`hY@%#)e@gS`0#O66@(?oZHk=1B`Q>JS;0MB!3 z*9fV-eRcV&M0t#>P!-PVDnkw%Uw%R5Y^Qce{!GPTl9AkP{qZNK3uuw1lM5^!7uIYB zTKRs`6Fb_R%+{qlFYbLneIC5EG0mu`Q8iPx|Hd4gB~@ar*buYuLxH2b!u-h-acbhV za;B?}y);+h+b?^H<&?r;FI7DpRFvO*Z}@ESopxvHoIXU-3GQiNdZQG&tg)%`()j>5U%8*=abH0rcqT%B6D0Zmr13 z%@CSLB&lHH>`Vd9u>cST)Zu#k+{=YK89x;KAb(BTN5B1aaI@&~+6zZIm60to1ZD^Y zJtr6t_2XHb5!@lqn9BC#RWMB~BuOHkDmxr!Iup6r9viKZ`w-efDaRhCW*G*H9#@(SDWjRaR*#~p`m z?xU1Ii&@X1qeVEW&|t_{ZNFqa((&g=JIRMx0!My;19V~=El!IiE*0!NF~eZlmf3lB zIPGiqe0tAY3SoB)6`!oiSEgj47sq4IRK}#O-j=)frC!=!#@7BjL+1Ux)+T_WLPFwp zUzUBo2}C$g6KF3uovYVHbT`*xrF%U=+IAa!tP;p zUXLMpbKG+dr8|nHIQMBV)>3aHy$7zfaeV3ax6#SEZ6TbCIScF|^_e32;8X_IKZHOw zZxkswt;v^ieAvSdpX*9+8EoZv=H)hAc`r$jH zzLvhF2YqI6Quk4Hu~KC6zGpbFeQJS_+a%Zn%jCZk$&oyBW;VNHQq;Xu%Rxf%qHo|e z$7+%gv|=zBSo(1A`AJdO;O3CCQZZYgU?JMbzSgx&Cf^Q%7BKir>Mz#p328e5xBGW8 z6%|_DZjA)Ck2#ert;L<)?z3}Oa_)TYF^fhCg&69!3i}xMa?o^dvb7*Pp*{zX>?Nlc zP<>6x_CV-4q{A-0ImkEU0oW}uH576_-Q+qSOYsgB7kBdIv6Tj@c1%ra!%>2>kSsT8 z#>twv$3e6F0V#xTpHC8Vd;a%~*RDyHLQl23RZ$-}8^aK)TywO2N^SaF_s$-1J^NaJ zpO=Gnk`n7KM`~9u*SguGF9=ya*u`QBpG__}Bv@5tauSTKbfyx=rkdz6K^VnuUJuWQ zyeWZO?rFO0mChh~#Fa^j6KExTA@AZ$Ul?sI_0qmRvjm`#33zWwH1TY!^FUP`b`(0$ zHRyFSNaw-<^Vymlu9+O7b}I*PzvxqxS+ZE|8k*R6mm<;~A(fnS49WV5qY0rm3VC>z zi94FA-g0Kj`~wqdPt^8DiY9fMmuI(-CWR7uL=NNa|ftZq4p%jMoS?(!s}&AuSLc zLQ$OQ8Ngrv_sKl|!69<_IR)doF~?T4H$*3KL+Ajh%|sPp#}wCJw`e_1 z6e{=VcVa zy(|=)AH#&7EcUY>4FDy(5v5Z?I(>(3rvg9ON2WX0mmA?h7u1cW0xF_~@#iL*fKVE! zJm~u)SDftLq@l;rvrXJmmkX{LaSE4rFL?wIo4CM~kyW+0Shi3Tc_N8LSfjjkw<>0X z3eWsTyomuy?aaceM@mm$EJx+<027oA!1(6;%6z`Ec5erVrxy9$dr3l{6*DpFCLC-b z-cCy;SWi4AnW;Oox_fV)o#rf2E;<8_(C?oe)}cRL081t65vX(?r;|GO2aNc8c`gs*{s+r7Lu7s*u554->4J1TLVx2o1*T zJSEHq7VeBZ<0U0`^F~o4K!DWLdj_7XYuqymE~t=_OOr{@WU(K;vd|D)Y&MB)JWJi1 zMTS+L!uC*W8wk|#GFvDKL2b?=1@F-jrO7X9x`Jd{SOh^!1y&3Ru9uIeepfrf4cdK?D~K^sAo;o?HD#iiq=pic0>_3?j8jvxz;^tS zjo~j@%dctYWTbx#mbbXU+uz37Z>QK!Wk5WT_zEn=W4ha9`6{8)Rfim(qrmRa{}wkE z%2tOC7UFJ=fd4j7whQGI-&gpDkLwRC=V8L7ZUX!Kg(6Ciuqc2O;Z^`nJB=&e@E3 z{hvEb3fzJ2m>^E)>9GsLf!E7C0yvl*jUM==p*KhGSZB2gyJjRNgyzH|q;QUU=F)<3>8T%eIUSUcixh z(%x3TS=s@j<;?*e*b7+3$Y}Mv5j5-z$=)g-@PHVnsKlg_3G?X-Eu3~GSUMqn?L2(l zTdJlqzEBFLr5cnNnR}(BHQ8|x`+JJX87PH=i5_MMEtVF2)Qb0}>CG4UB4%l)@vw>X z^XRrY{6a9P@r#_O;$J&|A--#DwDF%`6taYVQN==L(2te!;hAs$l^ad_dDxx?p+fez z|H58k#BHr|Ka%gg*IsG@?f{cvA;Otao zOT^<+6pOjjg4f{s~K&Mo?Bd=`c|W zh@gOIvobO%R669@)!E!UR5n}LR$E;dF~JqPU$H|fx)sLpKM7P3#T|=ucdCj@G6T?k zYS~iABAaquE{v5=EJ0BgIgNoXf=kCgn-JmWXR?d}U7ZiUZd*s4`y$3$KJkJIX+uAf zC;GpiMgJEcfAzJ~OOD0KckP`q#^?DgUQgskXc~U|P6Yea4+gceqaj*KdM})FIT|?x zM}0QEME{V=NDwl)Stf_%O{lCIISKjZw8<4$PVuC{?p;oZubGLaw82ICAirEO0g&Fh z-;zFg2)MXWwL?j*_=T(96qj2bROk;Y-U)uB>Q1NT`sk#t1{<2WA=xY@ybRcRZ|Ul= z)N(=5a*!Hn({9I!ww7l1S2kiIWjl6Z)Q@zsQ=Kf-?J5O*AdsF6qqGo95$CmYZ4@@y zN@kfoGs~)8xk;zSz9opBvoU&K1SJzC;Ir|^_f7=O$ z#zHy`q~kcRo%x)KpZLGHnO$)Wx|SQJzQrh?hA&Zu5~!obMoyItLNi6-_{dvsZkBSF zc%aMx1M~`S(Q|*=_FtdvXG2>;23rAap-0K}#Ljmjn&IC?U@!mhH{#joPp?ql-g_Ti zCX`~94H3iAUYcj;UM(**A8>CYG2tNfG>$C?5kV0@CBf4cC+I7e=qcbqm2Nn89(Kl( z45w|`tq7RxD9EtLxAR1@A%{KkOQ)>y8Kbq>J)54z-L+*K6)lKE3G=gnWyw;IloU3D zjci#64+`J~pnFOdx>vVeR#2gErb(|1(J{mB-tL-}(-qS^JlzFHZY-H$(|uU4lRSQT z4%|BqeOmj#9~d-HwrYpv$lGfoxe3AL&&CI;H$0a+PFWDksLHa^JnJem?jf%LA0)Pg4u zsbA#j`%c`p@bN&OrPBMm*{aT6(1Or8GnN!}O@GN4lheRTE+@?=HDgr!*^jMmU~8#i z0n;@WZ*7Yp1LD4G9C0ew*fR5Iur_+NT#TlG8>ckVi7gg`<^yHPOn!G-s6@?txk-#o zmc*`8uLJad=9{0*wjUfd2C!vxW^Z)G;j8kfEm8GK@w${FS{&Z@jiWq8TMo9HceI&k zi)D1GmM`FjT&q&KX0}%?no>hh)<*X(Y6J$psv9fg&gsompkr|qCFktsyQ5yLd{~NC zP*4#=#W6loxze!?qP_?X#tghL=z1PR;)clt#e#FX8pdk)RSriARc4JUlAF%Egj~-5 z?`$k1XVPb_@-*DvBzyTorT?@S|8M#7Z#K*t8v5f^M%VentLjO@;cffJSl$mXmQTQy zUyVK^SbXQVQqWmT;g?FXCXOIpR727AhKyBWv{m#`Jk$e4ib(Lik&w_rxQ@? zFnUhjLzSHTtJt)4Gbc4wR5H1}OdMg)Aj+mxh)c5HoHsmUmJQ4opX@k3(eeGWR5kkJ zmc*RQw8~(`iu_1_b%h%LeaZyptI8xZxq>;#*Q^xr#(p}vI_xTVk|cq!@j_{Dao(nr zSh_X6l}@I^A8&FK5}!>oTf5j}MK5Oyx7H}tPTpOY(O0nVImvECC zgAw{FG(LB_DE+}!Uv4LTI3>lI1j`bn2OLm>Q z1R}Arm{e?qBgOTO7z=|&f~T#&8RxBHqWK-D2deveIfT?nx64doE8HO^$rsr~w)Qs> ztvmBjZprq6F}RiYVf#BGUA5{_6JU?S%K21x^N}eY z-9a4D6dmhZgX{~{s49@T2%%im{ni!wGhaEf4C+mav8W9NoQU*jY7EIcy>F{bteJlZ zB(;fvuGgY4R~xe%cme4gUji*~%9*hv*-~@Kgt*Wez`^a{Jb|%2kVuz9%vLU=WNCX? zgz>GeqhjRrSEluyStxX{0gIwQ7{Ev@!YG_RyVn|>DeLAAg1|zNczrQ!o|FCH^)9Gl zaL}CGDAJeSoZ>zX_ZYp{9JyFc(H~_I{lrcSTJeMe_esU-J8T99CZHXlm z2f(qwD3pgNbt*9yY>%Ew9piJMuh_~8@y2nn!7M*+dgu?AzJdJNNS63KDG~pc`d8DF z$Tn-Y6z`b;zd`ah4S$l~pCfV9PMnOcac9~=^+`Li0jifYa9(dN{46;u(-h(CLeBKJ z*g+X%XLcoz=Ji8wy#1|O-SC+Z1kSfzom($ni~MxuJ=pu>@6Ua@LJ)$w9jvFC=Q{tM zlb8lh<;{g~bl9U$SKgz-J&qRcZ??Yo=}N5-Zl0gFFuLNkwB^&4_ptJhe?0u@%61`q z{69tdPc8kYFTEe3{~2-rXYJCxqMrtsX1gn!qPeRyw1d1VuWf2|_gCBhh^&7v(Ic|q zAC_H4K01D))Or@I-KM~fA-50=;Gr^vO0&|kqd~{zWO4~)j$+Nkj;c&ZZk{8{XWFr` z^mNx-ar3TcKc{bCu};&oo!l0)dbvnrBO$_ctZ!|vzm)R&;kxG@SoU`ff-n@afyp2D zJ>vnCjtX2Z6naKJmptuLkP`{X)w>pAYz;C~Pv?#3PW3GqJ|O7v>~%p+_LJG9Cru3! zGLe<2JG&Gd5PqCq1|;uvLx*cI_t3X{PDD3Kd1}x$Wm ze-Wy&e*J7J`m|1rrM`Z-Zo|0YPUw#nB)Rx%96}s8O!}sHo9qO~<`GRq$uoK#7HzKJ}8U^7XXu4UZ}+8d;J$j)5_f3)Xm!ch@Sta&717jZa)j2s3yr z=jN>jc2|B}77lPDmbx4&s4V0LjGX-EVStKxZ(Y~7zU6^FC%zVXp8voOy5-z^hu=LB zh{j3Vda!3kj`NL|6jgTk8Aw4Lr4U%0SyCxwxcRFiwW{uLtfDa1`BVq=2oh-K=tBC6 zPMe6PZ6iQ_tpS@<3Tom2xe>pyaylqy?XX`zTyj#lBFic8K)&K&HXy*$18G9U4_k3H zaf~HnTk&MGteB7#cm;ct1NBny&kw1{w!qLK8lE%2nedvP-n{PvSE1SGPW|jYV{R(I zj)X8UYMW6Y2XQGEPV>v0iWem{531LXw@({h+2wirj;V{HIn>1i2R3o?Tj7cCcNZAJ zNq49BY{FF{=6R@|vTGSX zrf^QXP1_-*tndt!&*4e=^?RzTWP5DctX>S{(lBQFYC9 zRy;dRGKaK>)=+4Sr}VbLX0Sa{ejF*?bWimx>BIZ5IcRBce0r5HEI-+_-H|>i&fqi# zzY|%E+M}y&rhC&mmR-(HXTr+$^bdOyr-(9C>L{nnmsb{Sp!_g5{^yZgM}x!`W(eZR zPvd45RIWWw7)?y4(CO#bh$2gu&vFc`5}q_8)HXpqP#$i(dC6c5Tgk=MK(IFB6dldz z_X@m{5uY2MDU}s3v(u`tD(ax;8BK+RN_svL=|rW4QK>nb1GW|B;r+xo5NPGV=E54>Y81Wcc znM@N?8VoF+1BEm%Xnrz+_~(^A_?cp&gSF z#`$<trW5zGEZX1PAAi2%W)vFl0BWU&rliojJTOi>nLGP`);IX>epVer z;DV;N=*EB7icsfK2V#@4pg!M0Az~TvnC-ADSs}p}u+wd#c{p>dH@rMhUw)7?jpxi@ zvV=iUl@npdOJ54E=%ZI`Dp@8!FGx7NXe3J|I`&C2B_?N=rVUOXjY8ViUNN&-P0-M7 z1Q<9c+1b*jgtioKLUf6-q)pXn1OjSN-jtG^3diWg+wCW0xFwpt#tx2o!ZiR+=e<>5 zmF2Lg4p->_ey-`=#p6Y&a@bI#NB^2f09i0t16M5PFRFA+sBcj_*V!6#!rH+J#k~`d z2JTaUaM|9zwJr1T@*$Fu>iDT1;Iu?%C*NXnl2ptw*ww-Z88nwR!4~M=Q_$?cyQ=?T zALTQ+`Y)lZ|KaWrU;H^V%OA16VyL*98t^HREa^OPI-XLJu`AbG+)RjJb`&l^P)(yt z$_|Sb`(GBqzz;?XYKlvQ@jVrtCz17%S*}Q*Qm5AoL7+UFOqndR9R(XAgo-#rp7_WZ4=USdY|>5YNIZJrPej zyq>=zOowjF>1>fm&4l$imlFggAkFJ#Y7IgM9xO<>m5*wo;r5(j)}`-|>FG0bva=RF zoWAn}`Fta?(!$*|Hp8mc!I3OfrJAK*UTLxvrT9IUIU52X=hZ`u3+95z_OFNN1L4ol zT%M@9K?*s=SM-(<~SV8OZtK?r>&b;-cS zBc`d;4nk*=Xr=6WOcpdEdRymBQ0;+fr6b$24diuv^Z4#_?83x_pw$YE?;f6Wl6c7a z&MwFI#_%$nCdm8-#K4hW;6gHsii%d{Ba_2-P4Kn=-|X$^KfJ|kABEIHYa1ms#a@u@ z=S^GtSFcOt`DmzIL$$g<^n1ZB$AYHr?5 z#wy&QUb#c%nwQDtdox1@Cn|RC2X3WTh^(jE%DPg?x~4t$^$Z)>f}VIt=Mq?nnHQw! zKHs4ie^E>Spk90n4=EhS9NnPnmpv~Uvc*j4dD+zXRKL-d<^F3C)1z<|^>4RDKifWM zec}YdcDpakpdD7g76O{Q-^5p#;b==Kj{zy@g)lC=6ak$h(&zUYogH)T+|6A2Y2*l@-8M z0sVGcmbKE5IVDmK>)ENTW*$8f?tzB~Rs}k?b(bzA*Po^e)eZi?KXgM5@T<+vkqyMr zf_n%=sIC5<>}_o_s_#TX4n(ZGA03REcs>12q%M41x#^wA^*6KXal5y#P!*V|md2`b zac%i_PIimh8WM{(%_kFCLWzrBf{>)sNnC!myr6lZ$MULL$eV2$htP=@Nt~uSkSbC4 zZR|Ud)<(CQ9D4pEx^lr)uQ`Jh^feT87Bhf)gL5}p+B@}nZV)rI{7&T6*qncx;m|KG zS5LE_;q%YItz|nJ;!=^WWp=FneD7ECJ5v)6w)FPy`q8J|u?WgYmk>0&Fb_x1o1^I= zWuDm+77cNBtw?H$#GCYrkP=W@`O4&;tH6n@7Zh@aaiaN6fxMSgutSRp#?6*m@8@@B zcVos-VMN|bTw!74%ZeAPZ?L4TkyUg;>K)v(1m_x}Og^3tN^Lk`v0Sj%1Zun2N78vk z5e3Of9WfR<;jCV7tMy9zwWI#0yH)LU<4xmD;}GT1a=N#1CC@ue7u4VWKyA;nmJ3fB zP2+-8iSk;5ieWvWgG=!K;YT&WvvaS*E1k;b7d-tl3t^F28xklF47TpwVyj3s$BhJ@ z?Si=Yx(hUNBT@6l$=U5w;uHPl+-98fO2wPn^O!~(invQw~Tb7Xg*Y^3N~$7 z{bp+%Za6j5WsAF77TheqrFq)U6Uzzhgn0MrEyoY)W!OfUxTyE8_ynm=2uAWq_JwiY z^mNdJPwvOo=UOGQ9+@4geZCH5ide=J<(4LqfSke!lQ<=BA1`vPTrOAGT5D}61 z?AI6nTk5Mb-`3*JVY!S)o~BrFddrH;Ol%iR7&7S+d8pD*@)}T8#X!JT�!-OG1e@ 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 P;QwiYKlhETpL_m44#n0I literal 0 HcmV?d00001 diff --git a/docs/migration/保真迁移状态.json b/docs/migration/保真迁移状态.json index 4ca70fb..1cf0da8 100644 --- a/docs/migration/保真迁移状态.json +++ b/docs/migration/保真迁移状态.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "updated_at": "2026-07-31T08:55:17+08:00", + "updated_at": "2026-07-31T11:52:29+08:00", "status": "active", "migration_mode": "behavior_preserving_source_migration", "source_of_truth": "current_original_webapp_runtime_and_source", @@ -9,10 +9,10 @@ "failed_roots": [ "next" ], - "current_slice": "slice-09-review-watchlist-notes-journal-alerts-assistant", - "last_completed_slice": "slice-08-heaven-trend-fortune-heart", - "last_checkpoint": "xiaobai-preservation-slice-08-20260731", - "next_action": "capture_slice-09_review_private_data_and_assistant_contracts_then_move_original_implementations", + "current_slice": "slice-10-frontend-shell-pages-components-css-mobile", + "last_completed_slice": "slice-09-review-watchlist-notes-journal-alerts-assistant", + "last_checkpoint": "xiaobai-preservation-slice-09-20260731", + "next_action": "capture_frontend_asset_dependency_map_then_relocate_original_shell_pages_components_css_and_mobile_responsibilities_without_runtime_changes", "authoritative_documents": [ "AGENTS.md", "docs/migration/原版保真迁移总纲.md", diff --git a/docs/migration/保真迁移账本.md b/docs/migration/保真迁移账本.md index cf44cf7..01c50d7 100644 --- a/docs/migration/保真迁移账本.md +++ b/docs/migration/保真迁移账本.md @@ -1,6 +1,6 @@ # 小白复盘保真迁移账本 -> 当前状态:正式迁移,切片08“问天、观势、观气与观心”已完成 +> 当前状态:正式迁移,切片09“复盘、自选、交易日志、提醒与复盘助手”已完成 本账本是上下文恢复和人工审计的连续记录。任何迁移提交必须在同一提交中更新本文件及 `保真迁移状态.json`。 @@ -61,6 +61,9 @@ | 公开`游资skills` | 运行资产 | 问师模型库 | 原样保留 | `app/游资skills/` | 190个文件逐路径和SHA-256一致 | 已复制 | | `heaven_agent.py`、`heaven_engine.py`与问天服务 | 业务计算 | 观势、观气、观心 | 机械移动并保留兼容别名 | `app/backend/features/heaven/` | Agent文件哈希、引擎定义AST、20个服务方法、6个API及真实动画页面等价 | 已移动 | | 问天历史方法 | 持久化 | 三类解读历史与当日解运复用 | 按职责机械移动 | `app/backend/features/heaven/repository.py` | 5个方法AST一致;62个schema对象及6张关键表逐行一致 | 已移动 | +| `assistant_agent.py`与复盘服务 | 业务服务 | 我的复盘、自选、交易日志与复盘助手 | 机械移动并保留兼容别名 | `app/backend/features/review/` | Agent文件哈希、8个服务方法AST、5个真实API及会员/非会员页面等价 | 已移动 | +| 复盘、自选、交易日志与助手消息方法 | 持久化 | 用户私有复盘数据 | 按职责机械移动并保持Mixin原接口 | `app/backend/features/review/repository.py` | 13个方法AST一致;相关表逐行一致;浏览器账户隔离通过 | 已移动 | +| 提醒编排与提醒持久化方法 | 业务服务/持久化 | 提醒中心与策略跟踪提醒 | 按职责机械移动 | `app/backend/features/alerts/` | 5个服务方法、6个Repository方法AST及真实API一致 | 已移动 | 处置只允许:`原样保留`、`移动`、`合并重复`、`待定`、`确认废弃`。 @@ -158,6 +161,16 @@ - 回档:标签`xiaobai-preservation-slice-08-20260731`。 - 完整证据:`docs/migration/evidence/slice-08/README.md`。 +已完成切片:`slice-09-review-watchlist-notes-journal-alerts-assistant`。 + +- 原版基线:提交`b3df070`,即切片08回档点。 +- 迁移范围:复盘助手Agent、5个提醒编排方法、8个复盘编排方法、19个持久化方法及5个HTTP入口。 +- 兼容边界:根级`assistant_agent.py`指向正式模块对象;提醒与交易日志旧服务继续作为唯一底层实现;包导出采用延迟加载以切断初始化循环。 +- API与数据库:5个固定输入真实API完全一致;62个schema对象及8张关键表逐行一致。 +- 验收:原版231项、迁移版287项Python测试、7项切片源码等价测试、45项Playwright及1920×1080会员/非会员真实页面通过。 +- 回档:标签`xiaobai-preservation-slice-09-20260731`。 +- 完整证据:`docs/migration/evidence/slice-09/README.md`。 + ## 决策记录 | 日期 | 决策 | 原因 |