diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..85e3b6a --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1 @@ +"""Application packages introduced by architecture governance.""" diff --git a/backend/bootstrap/__init__.py b/backend/bootstrap/__init__.py new file mode 100644 index 0000000..097a1fe --- /dev/null +++ b/backend/bootstrap/__init__.py @@ -0,0 +1,9 @@ +from .container import ApplicationContainer, build_application_container +from .settings import RuntimeSettings, load_runtime_settings + +__all__ = [ + "ApplicationContainer", + "RuntimeSettings", + "build_application_container", + "load_runtime_settings", +] diff --git a/backend/bootstrap/container.py b/backend/bootstrap/container.py new file mode 100644 index 0000000..4409557 --- /dev/null +++ b/backend/bootstrap/container.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from alert_service import AlertService +from chart_data_provider import EastmoneyChartClient, MarketChartClient +from database import ReviewDatabase +from ifind_client import IfindHttpClient +from mentor_agent import MentorSkillRegistry +from realtime_aggregator import WebRealtimeAggregator +from screener import ScreenerEngine +from strategy_tracking import StrategyTrackingService +from trade_journal import TradeJournalService + + +@dataclass(frozen=True) +class ApplicationContainer: + database: ReviewDatabase + ifind: IfindHttpClient + screener: ScreenerEngine + strategy_tracking: StrategyTrackingService + alert_service: AlertService + trade_journal: TradeJournalService + mentor_skills: MentorSkillRegistry + realtime_aggregator: WebRealtimeAggregator + chart_data: MarketChartClient + + +def build_application_container( + database: ReviewDatabase, + credentials: dict[str, object], + mentor_skills_dir: Path, + private_mentor_skills_dir: Path, +) -> ApplicationContainer: + ifind = IfindHttpClient( + str(credentials.get("ifind_refresh_token") or ""), + str(credentials.get("ifind_access_token") or ""), + ) + return ApplicationContainer( + database=database, + ifind=ifind, + screener=ScreenerEngine(database), + strategy_tracking=StrategyTrackingService(database), + alert_service=AlertService(database), + trade_journal=TradeJournalService(database), + mentor_skills=MentorSkillRegistry(mentor_skills_dir, private_mentor_skills_dir), + realtime_aggregator=WebRealtimeAggregator(), + chart_data=MarketChartClient(ifind, EastmoneyChartClient()), + ) diff --git a/backend/bootstrap/settings.py b/backend/bootstrap/settings.py new file mode 100644 index 0000000..e6a9a23 --- /dev/null +++ b/backend/bootstrap/settings.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Mapping + +from app_config import load_local_env, save_local_env +from security import SecretVault + + +def environment_credentials(environment: Mapping[str, str]) -> dict[str, str]: + return { + "tushare_token": str(environment.get("TUSHARE_TOKEN") or "").strip(), + "ifind_refresh_token": str(environment.get("IFIND_REFRESH_TOKEN") or "").strip(), + "ifind_access_token": str(environment.get("IFIND_ACCESS_TOKEN") or "").strip(), + "platform_llm_primary_api_key": str( + environment.get("LLM_PRIMARY_API_KEY") or environment.get("LLM_API_KEY") or "" + ).strip(), + "platform_llm_primary_base_url": str( + environment.get("LLM_PRIMARY_BASE_URL") + or environment.get("LLM_BASE_URL") + or "https://api.openai.com/v1" + ).strip(), + "platform_llm_primary_model": str( + environment.get("LLM_PRIMARY_MODEL") or environment.get("LLM_MODEL") or "" + ).strip(), + "platform_llm_fallback_api_key": str( + environment.get("LLM_FALLBACK_API_KEY") or "" + ).strip(), + "platform_llm_fallback_base_url": str( + environment.get("LLM_FALLBACK_BASE_URL") or "" + ).strip(), + "platform_llm_fallback_model": str( + environment.get("LLM_FALLBACK_MODEL") or "" + ).strip(), + } + + +@dataclass(frozen=True) +class RuntimeSettings: + encryption_key: str + initial_credentials: dict[str, str] + + +def load_runtime_settings() -> RuntimeSettings: + load_local_env() + encryption_key = os.environ.get("APP_ENCRYPTION_KEY", "").strip() + if not encryption_key: + encryption_key = SecretVault.generate_key() + save_local_env({"APP_ENCRYPTION_KEY": encryption_key}) + os.environ["APP_ENCRYPTION_KEY"] = encryption_key + return RuntimeSettings( + encryption_key=encryption_key, + initial_credentials=environment_credentials(os.environ), + ) diff --git a/docs/governance/architecture-inventory.json b/docs/governance/architecture-inventory.json index 520faa8..65fe985 100644 --- a/docs/governance/architecture-inventory.json +++ b/docs/governance/architecture-inventory.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "captured_from": "323c734", + "captured_from": "governed source tree", "runtime": { "http_server": "http.server.ThreadingHTTPServer", "application_processes": 1, @@ -265,8 +265,8 @@ }, { "path": "server.py", - "bytes": 269167, - "lines": 5965 + "bytes": 267525, + "lines": 5938 }, { "path": "static/redesign-v2.css", diff --git a/docs/governance/stage-05-bootstrap.md b/docs/governance/stage-05-bootstrap.md new file mode 100644 index 0000000..313f57c --- /dev/null +++ b/docs/governance/stage-05-bootstrap.md @@ -0,0 +1,24 @@ +# Stage 05: Bootstrap and Dependency Assembly + +Date: 2026-07-29 + +## Result + +- Environment and legacy LLM credential resolution moved to `backend/bootstrap/settings.py`. +- Encryption-key initialization remains behavior-compatible and server-side. +- Stable service construction moved to `backend/bootstrap/container.py`. +- `DashboardService` keeps its compatibility attributes but receives them from one application + container. +- The iFinD client is instantiated once and shared by chart services. +- HTTP routes, API payloads, background thread timing, database paths, and frontend assets are + unchanged. + +## Transitional Boundary + +Some methods still construct Tushare clients directly. Stage 06 introduces `DataGateway` and +migrates those provider creation paths without combining that work with bootstrap changes. + +## Rollback + +Reverting this stage restores inline service construction. No database or configuration +migration is required. diff --git a/server.py b/server.py index 88b116d..f5cd335 100644 --- a/server.py +++ b/server.py @@ -4,7 +4,6 @@ import argparse import copy import json import mimetypes -import os import re import secrets import threading @@ -16,10 +15,10 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any from urllib.parse import parse_qs, unquote, urlparse -from alert_service import AlertService from assistant_agent import ReviewAssistantError, stream_review_assistant from api_access import required_role -from chart_data_provider import ChartDataError, EastmoneyChartClient, MarketChartClient +from backend.bootstrap import build_application_container, load_runtime_settings +from chart_data_provider import ChartDataError from app_config import ( DATA_DIR, MENTOR_SKILLS_DIR, @@ -30,12 +29,9 @@ from app_config import ( TOKEN_PATTERN, USERNAME_PATTERN, add_months as _add_months, - load_local_env, membership_boundary as _membership_boundary, normalize_date, parse_iso_datetime as _parse_iso_datetime, - remove_local_env, - save_local_env, tushare_code, validate_stock_code, validate_text, @@ -50,17 +46,15 @@ from heaven_engine import ( build_personal_field, hexagram_from_lines, ) -from ifind_client import IfindError, IfindHttpClient +from ifind_client import IfindError from llm_strategy import LLMCompilerError, compile_strategy_with_llm, test_llm_connection -from mentor_agent import MentorAgentError, MentorSkillRegistry, stream_with_mentor +from mentor_agent import MentorAgentError, stream_with_mentor from market_insights import MarketInsightsService -from realtime_aggregator import WebRealtimeAggregator from screener import ( FACTOR_FIELDS, FACTOR_GROUPS, REGIMES, FactorDataService, - ScreenerEngine, compile_local_strategy, ) from security import SecretVault, hash_password, token_hash, verify_password @@ -71,8 +65,6 @@ from sentiment_engine import ( build_sentiment_history, latest_contiguous_history, ) -from strategy_tracking import StrategyTrackingService -from trade_journal import TradeJournalService from tushare_client import TushareClient, TushareError, _sector_coverage_issue @@ -172,30 +164,8 @@ MENTOR_ETF_UNIVERSE = ( class DashboardService: def __init__(self) -> None: - load_local_env() - environment_credentials = { - "tushare_token": os.environ.get("TUSHARE_TOKEN", "").strip(), - "ifind_refresh_token": os.environ.get("IFIND_REFRESH_TOKEN", "").strip(), - "ifind_access_token": os.environ.get("IFIND_ACCESS_TOKEN", "").strip(), - "platform_llm_primary_api_key": os.environ.get( - "LLM_PRIMARY_API_KEY", os.environ.get("LLM_API_KEY", "") - ).strip(), - "platform_llm_primary_base_url": os.environ.get( - "LLM_PRIMARY_BASE_URL", os.environ.get("LLM_BASE_URL", "https://api.openai.com/v1") - ).strip(), - "platform_llm_primary_model": os.environ.get( - "LLM_PRIMARY_MODEL", os.environ.get("LLM_MODEL", "") - ).strip(), - "platform_llm_fallback_api_key": os.environ.get("LLM_FALLBACK_API_KEY", "").strip(), - "platform_llm_fallback_base_url": os.environ.get("LLM_FALLBACK_BASE_URL", "").strip(), - "platform_llm_fallback_model": os.environ.get("LLM_FALLBACK_MODEL", "").strip(), - } - encryption_key = os.environ.get("APP_ENCRYPTION_KEY", "").strip() - if not encryption_key: - encryption_key = SecretVault.generate_key() - save_local_env({"APP_ENCRYPTION_KEY": encryption_key}) - os.environ["APP_ENCRYPTION_KEY"] = encryption_key - self.vault = SecretVault(encryption_key) + runtime = load_runtime_settings() + self.vault = SecretVault(runtime.encryption_key) self.database = ReviewDatabase(DATA_DIR / "review.db") self.sync_lock = threading.Lock() self.auth_lock = threading.Lock() @@ -204,18 +174,21 @@ class DashboardService: self._auto_screener_last_attempt: dict[str, datetime] = {} self._ifind_event_lock = threading.Lock() self._request_context = threading.local() - self._system_credentials = self._load_system_credentials(environment_credentials) - self.ifind = IfindHttpClient( - str(self._system_credentials.get("ifind_refresh_token") or ""), - str(self._system_credentials.get("ifind_access_token") or ""), + self._system_credentials = self._load_system_credentials(runtime.initial_credentials) + self.container = build_application_container( + self.database, + self._system_credentials, + MENTOR_SKILLS_DIR, + PRIVATE_MENTOR_SKILLS_DIR, ) - self.screener = ScreenerEngine(self.database) - self.strategy_tracking = StrategyTrackingService(self.database) - self.alert_service = AlertService(self.database) - self.trade_journal = TradeJournalService(self.database) - self.mentor_skills = MentorSkillRegistry(MENTOR_SKILLS_DIR, PRIVATE_MENTOR_SKILLS_DIR) - self.realtime_aggregator = WebRealtimeAggregator() - self.chart_data = MarketChartClient(self.ifind, EastmoneyChartClient()) + self.ifind = self.container.ifind + self.screener = self.container.screener + self.strategy_tracking = self.container.strategy_tracking + self.alert_service = self.container.alert_service + self.trade_journal = self.container.trade_journal + self.mentor_skills = self.container.mentor_skills + self.realtime_aggregator = self.container.realtime_aggregator + self.chart_data = self.container.chart_data self.screener.ensure_builtin_strategies() self._background_stop = threading.Event() self._background_thread = threading.Thread( diff --git a/tests/test_bootstrap_container.py b/tests/test_bootstrap_container.py new file mode 100644 index 0000000..ca547f7 --- /dev/null +++ b/tests/test_bootstrap_container.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from backend.bootstrap import build_application_container +from backend.bootstrap.settings import environment_credentials +from database import ReviewDatabase + + +class BootstrapContainerTests(unittest.TestCase): + def test_environment_credentials_preserve_legacy_model_fallbacks(self) -> None: + result = environment_credentials( + { + "TUSHARE_TOKEN": " tushare ", + "IFIND_REFRESH_TOKEN": " refresh ", + "LLM_API_KEY": "legacy-key", + "LLM_BASE_URL": "https://legacy.example/v1", + "LLM_MODEL": "legacy-model", + } + ) + self.assertEqual(result["tushare_token"], "tushare") + self.assertEqual(result["ifind_refresh_token"], "refresh") + self.assertEqual(result["platform_llm_primary_api_key"], "legacy-key") + self.assertEqual(result["platform_llm_primary_base_url"], "https://legacy.example/v1") + self.assertEqual(result["platform_llm_primary_model"], "legacy-model") + + def test_container_shares_one_database_and_one_ifind_client(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + public_skills = root / "public" + private_skills = root / "private" + public_skills.mkdir() + private_skills.mkdir() + database = ReviewDatabase(root / "review.db") + container = build_application_container( + database, + {"ifind_refresh_token": "refresh-token", "ifind_access_token": "access-token"}, + public_skills, + private_skills, + ) + self.assertIs(container.database, database) + self.assertIs(container.screener.database, database) + self.assertIs(container.strategy_tracking.database, database) + self.assertIs(container.alert_service.database, database) + self.assertIs(container.trade_journal.database, database) + self.assertIs(container.chart_data.ifind, container.ifind) + self.assertTrue(container.ifind.configured) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/build_architecture_inventory.py b/tools/build_architecture_inventory.py index b056eef..7e1e80b 100644 --- a/tools/build_architecture_inventory.py +++ b/tools/build_architecture_inventory.py @@ -105,7 +105,7 @@ def build() -> dict[str, Any]: tables = database_inventory(database) return { "schema_version": 1, - "captured_from": "323c734", + "captured_from": "governed source tree", "runtime": { "http_server": "http.server.ThreadingHTTPServer", "application_processes": 1,