diff --git a/.agents/evolve/cycle-history.jsonl b/.agents/evolve/cycle-history.jsonl new file mode 100644 index 00000000..9023b8b7 --- /dev/null +++ b/.agents/evolve/cycle-history.jsonl @@ -0,0 +1,20 @@ +{"cycle":1,"timestamp":"2026-03-11T11:34:01Z","mode":"standard","selected_source":"beads","target":"mirofish-bio","result":"productive","summary":"Refreshed full upstream GitHub snapshot, hardened graph-build error normalization for upstream issue #139 embedded-traceback/non-JSON auth failures, updated triage notes, and passed targeted plus lightweight backend validation.","head":"449f37cd20057386149bfc86300b1d6d6d982f67"} +{"cycle":2,"timestamp":"2026-03-11T13:20:42Z","mode":"standard","selected_source":"beads","target":"mirofish-6hd","result":"productive","summary":"Added first-run upload guidance for smaller source documents and ~30-round simulations in the home UI and quick-start docs, refreshed upstream open/full snapshots, and marked upstream issue #19 as covered with a passing frontend build.","head":"cf4544eaa534d33a1c3b8bed8d8e2062dd754a1d"} +{"cycle": 3, "timestamp": "2026-03-11T14:24:21Z", "mode": "standard", "selected_source": "beads", "target": "mirofish-5ez", "result": "productive", "summary": "Refreshed upstream open/full snapshots, confirmed the remaining open PR queue is still limited to landed/superseded or unsafe branches, localized backend startup/request logs for English mode, and passed targeted plus lightweight backend validation.", "head": "69a39c7c7484d5ff25e9e8b94fdaf562478cdcd2"} +{"cycle":4,"timestamp":"2026-03-11T14:28:53Z","mode":"standard","selected_source":"beads","target":"mirofish-x5u","result":"productive","summary":"Hardened upstream GitHub sync HTTP fallback retries for transient 5xx/transport failures, added regression coverage, refreshed open/full upstream snapshots, and confirmed the previously failing full-history refresh now completes.","head":"d99be8db1c61ce5f3373c68acb882b10b654735b"} +{"cycle":5,"timestamp":"2026-03-11T16:14:23Z","mode":"standard","selected_source":"beads","target":"mirofish-kwk","result":"productive","summary":"Refreshed upstream open/full snapshots, confirmed the open PR queue still has no new safe cherry-picks, localized deterministic report-agent console_log.txt messages for English mode, and passed targeted plus lightweight backend validation.","head":"d7bcac21bdec027518beed5ff358e1da17fe750c"} +{"cycle":6,"timestamp":"2026-03-11T18:04:26Z","mode":"standard","selected_source":"beads","target":"upstream-intake-refresh","result":"unchanged","summary":"Refreshed upstream open/full snapshots to 40 open issues, 38 open PRs, 90 total issues, and 52 total PRs with all open PR refs and open issue mirrors still current, re-checked the safe-merge queue, and reran backend-lite plus frontend test/build validation without exposing a new actionable low-risk upstream fix.","head":"16780f1873bbb9c4d70afffed680817bc8d120ea"} +{"cycle":7,"timestamp":"2026-03-11T18:50:04Z","mode":"standard","selected_source":"beads","target":"mirofish-pft7","result":"productive","summary":"Localized standalone Reddit/Twitter simulation IPC close-environment confirmation and unknown-command payloads through the shared script message catalog, reconciled beads notes, and passed backend/tests/test_llm_env.py plus scripts/test_backend_lite.sh.","head":"2ca7b5f09a9f4da3ced37c787c63a9ba9fb6e873"} +{"cycle":8,"timestamp":"2026-03-11T21:58:57Z","mode":"standard","selected_source":"beads","target":"upstream-intake-refresh","result":"productive","summary":"Refreshed open/full upstream snapshots, confirmed all open issues and reviewed PR refs remain mirrored into the fork visibility artifacts, re-checked the safe-merge queue without finding a new clean upstream PR, and reconciled the human triage log with the latest intake timestamps.","head":"fa25d0fbbda774a14419e991460c420840fae6d9"} +{"cycle":9,"timestamp":"2026-03-11T22:12:26Z","mode":"standard","selected_source":"beads","target":"mirofish-kcba","result":"productive","summary":"Refreshed open/full upstream snapshots again, confirmed the remaining open PR queue still has no new safe cherry-pick, localized simulation_config_generator unknown fallback labels through the active locale, refreshed upstream coverage for issue #117, and passed focused plus lightweight backend validation.","head":"ce0ac89e97c4a9496e4436352d93c283077a6482"} +{"cycle":10,"timestamp":"2026-03-11T22:53:32Z","mode":"standard","selected_source":"beads","target":"upstream-intake-refresh","result":"productive","summary":"Refreshed open/full upstream snapshots again, confirmed all open PR refs and mirrored upstream issue summaries remain current without a new safe cherry-pick, re-verified the direct OPENAI_* backend path through both print_config_status.py and npm run check:backend-config, and reconciled the deterministic localization umbrella by closing mirofish-1nh and tracking the remaining model-generated locale drift separately.","head":"f218a4d7723e35914bf5fb92a7ad5e3498721a3d"} +{"cycle":11,"timestamp":"2026-03-11T23:04:18Z","mode":"standard","selected_source":"beads","target":"upstream-intake-refresh","result":"productive","summary":"Forced a no-cache open/full upstream snapshot refresh, confirmed the machine-readable summaries and fork mirror metadata remain current at 41 open issues / 39 open PRs and 91 total issues / 53 total PRs, re-checked the remaining open PR queue without finding a new safe cherry-pick, and reran the focused English-mode locale regression bundle without exposing another low-risk deterministic seam.","head":"c677684a5f665744c1dfb56401ff5d5cfb36a0a0"} +{"cycle": 12, "timestamp": "2026-03-11T23:26:30Z", "mode": "standard", "selected_source": "beads", "target": "mirofish-q5w8", "result": "productive", "summary": "Refreshed open/full upstream snapshots again at 41 open issues / 39 open PRs and 91 total issues / 53 total PRs, fixed scripts/refresh_upstream_snapshots.sh so --force-refresh no longer breaks repo selection, added subprocess regression coverage for the wrapper contract, and re-validated the wrapper through a real forced refresh plus unittest coverage.", "head": "c677684a5f665744c1dfb56401ff5d5cfb36a0a0"} +{"cycle":13,"timestamp":"2026-03-11T23:38:00Z","mode":"standard","selected_source":"beads","target":"upstream-intake-refresh","result":"productive","summary":"Refreshed open/full upstream snapshots again at 41 open issues / 39 open PRs and 91 total issues / 53 total PRs, confirmed all mirrored issue and PR visibility artifacts remain current in the fork, re-verified the direct OPENAI_* / Codex-compatible backend path through the current docs and validation bundle, and re-checked the remaining open PR queue without finding a new safe cherry-pick.","head":"2f4ccf9626c3278dcc969a1ed49b4d59120019dd"} +{"cycle":14,"timestamp":"2026-03-11T23:48:03Z","mode":"standard","selected_source":"beads","target":"mirofish-efoi","result":"productive","summary":"Refreshed open/full upstream snapshots again at 41 open issues / 39 open PRs and 91 total issues / 53 total PRs, confirmed the remaining open PR queue still has no new safe cherry-pick, hardened LLMClient to retry once without response_format when OpenAI-compatible backends reject JSON mode, added backend:local plus README startup guidance, and passed focused llm_client, backend-lite, and clean-shell OPENAI_* config validation.","head":"f86df0ff54ebad1547570c885478b0933a731177"} +{"cycle":15,"timestamp":"2026-03-12T00:02:03Z","mode":"standard","selected_source":"beads","target":"mirofish-v7ea","result":"productive","summary":"Forced another open/full upstream refresh at 41 open issues / 39 open PRs and 91 total issues / 53 total PRs, confirmed all open PR refs and issue mirrors remain current without a new safe cherry-pick, re-validated direct OPENAI_* / Codex-compatible backend detection against the config preflight, documented that ZEP_API_KEY remains separately required for Step 1 graph build, and passed focused llm_client plus backend-lite validation.","head":"f86df0ff54ebad1547570c885478b0933a731177"} +{"cycle":16,"timestamp":"2026-03-12T00:05:40Z","mode":"standard","selected_source":"beads","target":"mirofish-odkz","result":"productive","summary":"Forced another open/full upstream refresh at 41 open issues / 39 open PRs and 91 total issues / 53 total PRs, confirmed all open PR refs and issue mirrors remain current without a new safe upstream cherry-pick, revalidated ontology/schema normalization plus the direct OPENAI_* / Codex-compatible backend path, and passed focused ontology/graph-builder/llm-client coverage plus backend-lite validation.","head":"b51999c8727e7798ce11a6dcc32908539f11f70f"} +{"cycle": 17, "timestamp": "2026-03-12T00:12:07Z", "mode": "standard", "selected_source": "beads", "target": "upstream-intake-refresh", "result": "productive", "summary": "Forced another open/full upstream refresh at 41 open issues / 39 open PRs and 91 total issues / 53 total PRs, confirmed all open PR refs and issue mirrors remain current without a new safe upstream cherry-pick, revalidated the direct OPENAI_* / Codex-compatible backend path in a clean shell, and passed backend-lite validation again.", "head": "e438dc9ccf7d4b0fc1b0bcf1ee168a121d58b908"} +{"cycle":18,"timestamp":"2026-03-12T01:02:47Z","mode":"standard","selected_source":"beads","target":"upstream-intake-refresh","result":"productive","summary":"Forced another open/full upstream refresh at 42 open issues / 40 open PRs and 92 total issues / 54 total PRs, confirmed all open PR refs and issue mirrors remain current without a new safe upstream cherry-pick, revalidated the direct OPENAI_* / Codex-compatible backend path in a clean shell, reran backend-lite validation, and re-audited the remaining partial umbrellas without finding a new low-risk child issue beyond the existing tracked design-sized gaps.","head":"97d8fbd23d1c65cc62f16739ad1e159cf399344a"} +{"cycle":19,"timestamp":"2026-03-12T01:16:33Z","mode":"standard","selected_source":"beads","target":"mirofish-3yab","result":"productive","summary":"Forced another open/full upstream refresh at 42 open issues / 40 open PRs and 92 total issues / 54 total PRs, confirmed all issue and PR mirrors remain current without a new safe upstream cherry-pick, revalidated the direct OPENAI_* / Codex-compatible backend path with SECRET_KEY set in a clean shell, reran backend-lite validation, and recorded that the remaining actionable gaps are still the existing design-sized tracked items.","head":"c3f021c4d56aa5b48e63eaf951e2a92af246b3d0"} +{"cycle":20,"timestamp":"2026-03-12T02:01:27Z","mode":"standard","selected_source":"beads","target":"mirofish-ke3l","result":"productive","summary":"Refreshed open/full upstream snapshots again at 45 open issues / 40 open PRs and 95 total issues / 54 total PRs, confirmed all issue and PR mirrors remain current without a new safe upstream cherry-pick, revalidated the direct OPENAI_* / Codex-compatible backend path with SECRET_KEY set in a clean shell, and reran backend-lite validation while the remaining gaps stayed design-sized.","head":"425f6c967950e01412e4df745991e6aaa6fd98d7"} diff --git a/.agents/evolve/session-state.json b/.agents/evolve/session-state.json new file mode 100644 index 00000000..bbf60480 --- /dev/null +++ b/.agents/evolve/session-state.json @@ -0,0 +1,26 @@ +{ + "cycle": 20, + "mode": "standard", + "test_first": true, + "repo_profile_path": null, + "generator_empty_streak": 0, + "last_selected_source": "beads", + "claimed_work": null, + "queue_refresh_count": 20, + "pinned_queue": null, + "pinned_queue_file": null, + "pinned_queue_index": 0, + "pinned_queue_completed": [], + "pinned_queue_escalated": [], + "unblock_depth": 0, + "unblock_failures": 0, + "unblock_chain": [], + "last_completed_issue": "mirofish-ke3l", + "last_validation_commands": [ + "env -i PATH=\"$PATH\" HOME=\"$HOME\" OPENAI_API_KEY=test-key OPENAI_API_BASE_URL=https://codex.example.test/v1 OPENAI_MODEL=gpt-4.1-mini ZEP_API_KEY=test-zep SECRET_KEY=test-secret npm run check:backend-config -- --compact", + "bash ./scripts/test_backend_lite.sh" + ], + "last_upstream_snapshot": "docs/upstream-all-state.json", + "last_cycle_timestamp": "2026-03-12T02:01:27Z", + "last_cycle_head": "425f6c967950e01412e4df745991e6aaa6fd98d7" +} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl new file mode 100644 index 00000000..345b833d --- /dev/null +++ b/.beads/issues.jsonl @@ -0,0 +1,317 @@ +{"id":"mirofish-umau","title":"Restore refreshed_at alias in upstream sync snapshots","description":"docs/upstream-open-state.json and docs/upstream-all-state.json currently write captured_at/generated_at but leave refreshed_at null, which weakens the machine-readable intake contract for downstream tooling. Update scripts/sync_upstream_github.py to emit refreshed_at alongside the existing timestamp fields, add focused regression coverage, and regenerate the upstream open/full snapshots.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T03:28:50Z","created_by":"Codex","updated_at":"2026-03-12T03:30:55Z","closed_at":"2026-03-12T03:30:55Z","close_reason":"sync_upstream_github.py now emits refreshed_at on both live refresh and cached-snapshot reuse paths, added focused regression coverage, and regenerated the upstream open/full snapshots with non-null refreshed_at timestamps.","dependencies":[{"issue_id":"mirofish-umau","depends_on_id":"mirofish-2ul1","type":"discovered-from","created_at":"2026-03-12T03:28:49Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-vhmx","title":"Refresh upstream intake and execute next safe action (pass 29)","description":"Continuation of the autonomous evolve loop for March 12, 2026. Refresh upstream issue/PR snapshots from 666ghj/MiroFish, keep fork mirrors current, re-evaluate open PRs for safe cherry-picks, and land the next low-risk reproducible fix or record the exact blocker and next action.","status":"closed","priority":1,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T03:28:50Z","created_by":"Codex","updated_at":"2026-03-12T03:30:55Z","closed_at":"2026-03-12T03:30:55Z","close_reason":"Refreshed upstream open/full snapshots and fork mirror visibility again (46 open issues / 40 open PRs; 96 total issues / 54 total PRs), confirmed there is still no new safe upstream PR to cherry-pick beyond the already-landed/superseded queue, and fixed the refreshed_at machine-readable snapshot alias with tests.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-7i3a","title":"Persist TaskManager state across backend restarts","description":"TaskManager still stores graph/report task status purely in memory. That undermines the repo-native replay/recovery work because restarting the backend drops task progress/status for graph builds and report generation. Implement durable task-state persistence with atomic writes/loading, add focused regression coverage, and refresh upstream triage as a repo-native follow-up to the task-persistence portion bundled into upstream PR #152.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T03:24:54Z","created_by":"Codex","updated_at":"2026-03-12T03:26:45Z","closed_at":"2026-03-12T03:26:45Z","close_reason":"Persisted TaskManager state to disk with atomic writes/startup reloads, added focused regression coverage, and refreshed upstream PR #152 coverage snapshots.","dependencies":[{"issue_id":"mirofish-7i3a","depends_on_id":"mirofish-qoo","type":"discovered-from","created_at":"2026-03-12T03:24:53Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-6h1s","title":"Preflight Step 4 report config before async launch","description":"Follow-up under mirofish-gd5z. /api/report/generate currently starts an async report task even when backend config is incomplete (for example direct OPENAI_* aliases are valid but ZEP_API_KEY is missing), so users only learn Step 4 is impossible after the report task fails. Reuse the existing structured backend config payload in the report API, add regression coverage, and reconcile upstream issue #156 notes once landed.","status":"closed","priority":1,"issue_type":"task","owner":"codex@local","created_at":"2026-03-12T02:43:06Z","created_by":"Codex","updated_at":"2026-03-12T02:43:58Z","closed_at":"2026-03-12T02:43:58Z","close_reason":"Completed: /api/report/generate now fails fast with the same structured backend-config payload used by graph endpoints instead of launching a doomed async Step 4 task when config is incomplete; added regression coverage in backend/tests/test_report_api_i18n.py and revalidated with backend-lite.","dependencies":[{"issue_id":"mirofish-6h1s","depends_on_id":"mirofish-gd5z","type":"discovered-from","created_at":"2026-03-12T02:43:05Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-v3wx","title":"Add history modal Markdown export for forecast verification","description":"Execution-ready child for upstream issue #158 / beads parent mirofish-gytl. The backend already exposes GET /api/report/\u003creport_id\u003e/download, but the homepage history modal still only links back into Step 4. Add a low-risk frontend affordance to export the saved report Markdown directly from history when report_id exists, with focused regression coverage so users can preserve verification artifacts without reopening the report page.","status":"closed","priority":1,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T02:16:59Z","created_by":"Codex","updated_at":"2026-03-12T02:20:37Z","closed_at":"2026-03-12T02:20:37Z","close_reason":"Added direct Markdown export from the history modal for saved reports, covered by frontend/tests/historyReportDownload.test.mjs and a passing frontend test/build refresh.","dependencies":[{"issue_id":"mirofish-v3wx","depends_on_id":"mirofish-gytl","type":"discovered-from","created_at":"2026-03-12T02:16:59Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-kkgs","title":"Add copyable report/simulation references for verification workflow","description":"Follow-up from upstream issue #158 and beads parent mirofish-gytl. Step 4 already surfaces report_id and simulation_id, but the manual verification workflow still lacks direct copy affordances and the history modal does not surface both IDs together. Add low-risk frontend UX so users can copy these stable references from Step 4 and history, with lightweight regression coverage.","status":"closed","priority":1,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T02:11:52Z","created_by":"Codex","updated_at":"2026-03-12T02:14:26Z","closed_at":"2026-03-12T02:14:26Z","close_reason":"Added copyable report/simulation references in Step 4 and history, validated with frontend tests/build, and refreshed upstream issue #158 coverage artifacts.","dependencies":[{"issue_id":"mirofish-kkgs","depends_on_id":"mirofish-gytl","type":"discovered-from","created_at":"2026-03-12T02:11:52Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-pac6","title":"Repair conflicted upstream intake artifacts and refresh live triage","description":"The checked-in upstream snapshot artifacts currently contain unresolved merge-conflict markers in docs/upstream-open-state.json, docs/upstream-all-state.json, and their summaries/triage notes, which breaks machine-readable intake parsing. Regenerate the upstream open/full snapshots cleanly from 666ghj/MiroFish, preserve fork mirror visibility, and re-run safe-merge triage from the repaired data.","status":"closed","priority":1,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T02:06:29Z","created_by":"Codex","updated_at":"2026-03-12T02:09:16Z","closed_at":"2026-03-12T02:09:16Z","close_reason":"Repaired conflicted upstream snapshot artifacts, refreshed live upstream issue/PR intake, revalidated safe-merge coverage, and added Step 4 report ID/simulation ID verification affordances.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-ke3l","title":"Refresh upstream intake and confirm next actionable gap (pass 28)","description":"Continuation of the autonomous evolve loop for March 12, 2026. Refresh docs/upstream-open-state.json and docs/upstream-all-state.json from 666ghj/MiroFish, keep fork issue/PR mirrors current where practical, re-evaluate open PRs for safe cherry-picks, and either land the next low-risk actionable fix or record the exact blocker/next action in repo artifacts.","status":"closed","priority":1,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T02:00:26Z","created_by":"Codex","updated_at":"2026-03-12T02:01:52Z","closed_at":"2026-03-12T02:01:52Z","close_reason":"Refreshed live upstream open/full snapshots to 45 open issues / 40 open PRs and 95 total issues / 54 total PRs, confirmed all issue and PR mirrors remain current without a new safe upstream cherry-pick, revalidated the direct OPENAI_* / Codex-compatible backend path, and reran backend-lite validation while the remaining actionable gaps stayed design-sized.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-dmw2","title":"Add repo-native deletion flow for history records from upstream issue #157","description":"Upstream issue #157 asks how to delete unwanted history entries from the homepage list. The current repo has history replay plus project/report delete endpoints, but no simulation-history delete flow in either backend or frontend. Add a safe repo-native delete path that removes a simulation record and its local attached artifacts from the history UI/API, with regression coverage and updated upstream triage notes.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T01:54:54Z","created_by":"Codex","updated_at":"2026-03-12T01:59:08Z","closed_at":"2026-03-12T01:59:08Z","close_reason":"Implemented repo-native history deletion flow and refreshed upstream coverage.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-h3h8","title":"Restore upstream snapshot coverage annotations after refresh","description":"The refreshed docs/upstream-open-state.json entries no longer carry coverage_status / coverage_summary from docs/upstream-coverage.json even though every current open upstream issue and PR is covered there. Fix scripts/sync_upstream_github.py so refreshed snapshots preserve machine-readable coverage annotations used for PR/issue triage, then re-run the upstream refresh and validate the fields on open entries.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T01:20:00Z","created_by":"Codex","updated_at":"2026-03-12T01:21:12Z","closed_at":"2026-03-12T01:21:12Z","close_reason":"Restored backward-compatible coverage_status / coverage_summary aliases in scripts/sync_upstream_github.py, refreshed upstream snapshots, and verified generated open issue/PR entries again expose machine-readable coverage annotations.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-3yab","title":"Refresh upstream intake and confirm next actionable gap (pass 27)","description":"Continuation of the autonomous evolve loop for March 12, 2026. Refresh docs/upstream-open-state.json and docs/upstream-all-state.json from 666ghj/MiroFish, keep fork issue/PR mirrors current where practical, re-evaluate open PRs for safe cherry-picks, and either land the next low-risk actionable fix or record the exact blocker/next action in repo artifacts.","status":"closed","priority":1,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T01:15:55Z","created_by":"Codex","updated_at":"2026-03-12T01:16:29Z","closed_at":"2026-03-12T01:16:29Z","close_reason":"Forced another open/full refresh at 42 open issues / 40 open PRs and 92 total issues / 54 total PRs, confirmed all issue/PR mirrors remain current with no new safe upstream cherry-pick, revalidated the direct OPENAI_* / Codex-compatible backend path, and reran backend-lite validation while the remaining gaps stayed design-sized.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-h5ud","title":"Decouple backend config preflight from Flask app imports","description":"Scoped follow-up from mirofish-rhcb and the direct OpenAI-compatible backend verification path. backend/scripts/print_config_status.py currently imports app.config through the app package, which executes backend/app/__init__.py and fails with ModuleNotFoundError: flask in lightweight shells before the backend runtime is installed. Load config/i18n without importing the Flask app package, add focused regression coverage, and verify the script works with plain python3 plus OPENAI_* aliases.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T00:57:35Z","created_by":"Codex","updated_at":"2026-03-12T00:58:22Z","closed_at":"2026-03-12T00:58:22Z","close_reason":"print_config_status.py now loads Config through a synthetic package instead of importing app/__init__.py, config/i18n now tolerate missing Flask/python-dotenv for standalone preflight use, focused config-status tests pass, and plain python3 validation works with OPENAI_* aliases.","dependencies":[{"issue_id":"mirofish-h5ud","depends_on_id":"mirofish-rhcb","type":"discovered-from","created_at":"2026-03-12T00:57:34Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-rhcb","title":"Refresh upstream intake and execute next safe action (pass 24)","description":"Continuation of the autonomous evolve loop for March 12, 2026. Refresh upstream issue/PR snapshots from 666ghj/MiroFish, keep fork mirrors current, re-evaluate open PRs for safe cherry-picks, and land the next low-risk reproducible fix or record the exact blocker and next action.","status":"closed","priority":1,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T00:56:13Z","created_by":"Codex","updated_at":"2026-03-12T00:58:31Z","closed_at":"2026-03-12T00:58:31Z","close_reason":"Refreshed live upstream snapshots/mirrors (still 42 open issues / 40 open PRs), confirmed there were no new safe upstream merges beyond prior triage, and landed a repo-native fix so backend/scripts/print_config_status.py now works in plain python3 preflight shells for direct OPENAI_* / Codex-compatible verification without requiring Flask or python-dotenv.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-rcp3","title":"Refresh upstream intake and execute next safe action (pass 24)","description":"Continuation of the autonomous evolve loop for March 12, 2026. Refresh upstream issue/PR snapshots from 666ghj/MiroFish, keep fork mirrors current, re-evaluate open PRs for safe cherry-picks, and land the next low-risk reproducible fix or record the exact blocker and next action.","status":"closed","priority":1,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T00:53:58Z","created_by":"Codex","updated_at":"2026-03-12T00:54:22Z","closed_at":"2026-03-12T00:54:22Z","close_reason":"Refreshed upstream snapshots to 42 open issues / 40 open PRs and 92 issues / 54 PRs total, revalidated direct OPENAI_* backend config, confirmed all upstream issue/PR mirrors remain current, and recorded that no new safe upstream PR or low-risk reproducible bug surfaced beyond existing tracked follow-ups.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-qd30","title":"Refresh upstream intake and execute next safe action (pass 23)","description":"Continuation of the autonomous evolve loop for March 12, 2026. Refresh upstream issue/PR snapshots from 666ghj/MiroFish, keep fork mirrors current, re-evaluate open PRs for safe cherry-picks, and land the next low-risk reproducible fix or record the exact blocker and next action.","status":"closed","priority":1,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T00:47:01Z","created_by":"Codex","updated_at":"2026-03-12T00:51:41Z","closed_at":"2026-03-12T00:51:41Z","close_reason":"Refreshed upstream snapshots to 42 open issues / 40 open PRs and 92 issues / 54 PRs total, automated missing PR mirroring during sync, mirrored and triaged upstream PR #155 into origin/mirror/upstream-pr-155, revalidated direct OPENAI_* backend config, and updated machine-readable coverage/docs.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-n7j0","title":"Refresh Process graph view when polled graph payload changes without node-count delta","description":"While auditing upstream issue #145 follow-up surfaces, the Process view poller was found to update the rendered graph only when the raw node count changes. That misses edge-only graph mutations, alias-collapse remaps, and same-count node replacements during live graph polling. Patch the frontend to compare a fuller graph signature before skipping rerender, and add regression coverage.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T00:41:17Z","created_by":"Codex","updated_at":"2026-03-12T00:42:19Z","closed_at":"2026-03-12T00:42:19Z","close_reason":"Process.vue now compares a normalized graph signature instead of raw node count during live polling, so edge-only and same-count graph mutations trigger rerender. Covered by frontend/tests/processGraphData.test.mjs and validated with npm --prefix frontend test plus npm --prefix frontend run build.","dependencies":[{"issue_id":"mirofish-n7j0","depends_on_id":"mirofish-975","type":"discovered-from","created_at":"2026-03-12T00:41:16Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-99qu","title":"Handle structured profile fields from upstream issue #154","description":"Upstream issue #154 reports that simulation profile serialization crashes when LLM output returns structured JSON objects for persona/bio/country instead of plain strings. Reproduce the failing serialization path, make the runtime normalize or stringify structured profile fields safely, add focused regression coverage, and update upstream coverage/triage after landing.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T00:28:09Z","created_by":"Codex","updated_at":"2026-03-12T00:30:22Z","closed_at":"2026-03-12T00:30:22Z","close_reason":"Normalized structured profile fields in OasisAgentProfile and serializers, added regression coverage, and refreshed upstream coverage/triage for issue #154.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-odkz","title":"Refresh upstream intake and execute next safe action (pass 16)","description":"Continuation of the autonomous evolve loop for March 12, 2026. Refresh docs/upstream-open-state.json and docs/upstream-all-state.json from 666ghj/MiroFish, keep fork issue/PR mirrors current where practical, re-evaluate open PRs for safe cherry-picks, and either land the next low-risk actionable fix or record the exact blocker/next action in repo artifacts.","status":"closed","priority":1,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T00:05:25Z","created_by":"Codex","updated_at":"2026-03-12T00:07:33Z","closed_at":"2026-03-12T00:07:33Z","close_reason":"Forced another open/full refresh at 41 open issues / 39 open PRs and 91 total issues / 53 total PRs, confirmed all issue/PR mirrors remain current with no new safe upstream cherry-pick, revalidated ontology/schema normalization and the direct OPENAI_* / Codex-compatible backend path, and reran focused coverage plus backend-lite validation.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-v7ea","title":"Refresh upstream intake and execute next safe action","description":"Continuation of the autonomous evolve loop for March 12, 2026. Refresh docs/upstream-open-state.json and docs/upstream-all-state.json from 666ghj/MiroFish, keep fork issue/PR mirrors current where practical, re-evaluate open PRs for safe cherry-picks, and either land the next low-risk actionable fix or record the exact blocker/next action in repo artifacts.","status":"closed","priority":1,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T00:01:54Z","created_by":"Codex","updated_at":"2026-03-12T00:04:00Z","closed_at":"2026-03-12T00:04:00Z","close_reason":"Forced the upstream open/full refresh again, confirmed all issue/PR mirrors remain current with no new safe upstream cherry-pick, revalidated the direct OPENAI_* / Codex-compatible backend path, documented the remaining ZEP_API_KEY requirement for Step 1 graph build, and reran focused llm_client plus backend-lite validation.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-h69v","title":"Align frontend graph counters with deduplicated alias rendering","description":"Scoped follow-up to mirofish-975. The frontend graph views already collapse obvious alias duplicates for display, but Step 1 / Process counters and MainView refresh logs still reported raw graph payload counts. Route those stats through the shared graph normalization helper, add focused regression coverage, and reconcile the parent issue once landed.","status":"closed","priority":1,"issue_type":"bug","owner":"codex@local","created_at":"2026-03-11T23:55:15Z","created_by":"Codex","updated_at":"2026-03-11T23:55:20Z","closed_at":"2026-03-11T23:55:20Z","close_reason":"Frontend graph stats now use the shared normalization helper, so Step 1 / Process counts and MainView refresh logs match the deduplicated alias-collapsed graph display; covered by frontend/tests/graphPanelData.test.mjs and validated with npm --prefix frontend test plus npm --prefix frontend run build.","dependencies":[{"issue_id":"mirofish-h69v","depends_on_id":"mirofish-975","type":"discovered-from","created_at":"2026-03-11T23:55:15Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-efoi","title":"Harden direct OpenAI-compatible backend fallback","description":"Scoped follow-up for the current evolve pass. backend/app/utils/llm_client.py still raises immediately when an OpenAI-compatible backend rejects response_format=json_object, even though several repo-native generators and tools may request JSON mode against Codex/OpenAI-compatible gateways. Land the minimal safe subset from the side branch: detect response_format rejection, retry once without JSON mode, cover BadRequestError/APIError paths in backend/tests/test_llm_client.py, and add a backend:local npm script plus README notes so direct OPENAI_* / Codex-compatible startup runs the config preflight first.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T23:47:10Z","created_by":"Codex","updated_at":"2026-03-11T23:48:03Z","closed_at":"2026-03-11T23:48:03Z","close_reason":"LLMClient now retries once without response_format when OpenAI-compatible backends reject JSON mode, covered by focused llm_client tests plus backend-lite validation; package.json now exposes backend:local and README/README-EN document the preflighted direct backend startup path.","dependencies":[{"issue_id":"mirofish-efoi","depends_on_id":"mirofish-pfbl","type":"discovered-from","created_at":"2026-03-11T23:47:09Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-u1ff","title":"Expose merged alias names in graph detail panels","description":"Scoped follow-up to mirofish-975. The repo now collapses obvious duplicate entity aliases in graph payloads and renderers, but the Process and shared GraphPanel detail panels still hide which source labels were folded into the canonical node. Surface alias_names in those node detail drawers, keep canonical name first and avoid repeating it in the alias list, add focused frontend helper coverage, and reconcile mirofish-975 notes after landing.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T23:29:40Z","created_by":"Codex","updated_at":"2026-03-11T23:31:33Z","closed_at":"2026-03-11T23:31:33Z","close_reason":"Completed: exposed merged alias names in both Process and GraphPanel node detail panels via a shared frontend helper, added focused helper coverage in frontend/tests/graphAliasDetails.test.mjs, and validated with npm --prefix frontend test plus npm --prefix frontend run build","dependencies":[{"issue_id":"mirofish-u1ff","depends_on_id":"mirofish-975","type":"discovered-from","created_at":"2026-03-11T23:29:40Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-q5w8","title":"Fix refresh_upstream_snapshots.sh flag parsing for no-cache refreshes","description":"The upstream intake wrapper currently treats a leading flag like --force-refresh as the repo positional argument and forwards an empty/invalid --repo to scripts/sync_upstream_github.py, which breaks autonomous refresh passes. Make the wrapper parse optional flags safely, preserve the default repo when no explicit owner/repo is passed, and add regression coverage for the invocation contract.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T23:24:56Z","created_by":"Codex","updated_at":"2026-03-11T23:26:15Z","closed_at":"2026-03-11T23:26:15Z","close_reason":"refresh_upstream_snapshots.sh now parses repo/no-cache flags safely, the wrapper refresh completed with --force-refresh, and regression coverage was added in tests/test_refresh_upstream_snapshots.py","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-2t9x","title":"Collapse obvious duplicate entity aliases in shared GraphPanel rendering","description":"Scoped follow-up to mirofish-975. frontend/src/components/GraphPanel.vue still renders raw graphData nodes and edges directly, so shared graph views outside Process can still show obvious duplicate aliases such as 美国总统特朗普 vs 特朗普 even though Process uses the conservative alias-collapse mapper. Reuse the existing repo-native graph mapping/dedup logic in GraphPanel rendering, keep it display-only, add focused frontend regression coverage, and reconcile upstream coverage plus tracker notes once landed.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T23:21:13Z","created_by":"Codex","updated_at":"2026-03-11T23:22:43Z","closed_at":"2026-03-11T23:22:43Z","close_reason":"GraphPanel now reuses the conservative alias-collapse mapping through frontend/src/components/graphPanelData.js, with focused regression coverage in frontend/tests/graphPanelData.test.mjs and a passing frontend build.","dependencies":[{"issue_id":"mirofish-2t9x","depends_on_id":"mirofish-975","type":"discovered-from","created_at":"2026-03-11T23:21:12Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-4xn3","title":"Localize generator JSON-mode fallback warnings","description":"Scoped follow-up to mirofish-pfbl. OasisProfileGenerator and SimulationConfigGenerator still emit an English-only warning when an OpenAI-compatible backend rejects response_format=json_object and the code retries without JSON mode. Route those deterministic warnings through locale-aware copy, add focused regression coverage, and reconcile the parent localization bead once landed.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T23:17:49Z","created_by":"Codex","updated_at":"2026-03-11T23:18:37Z","closed_at":"2026-03-11T23:18:37Z","close_reason":"OasisProfileGenerator and SimulationConfigGenerator now localize the response_format=json_object fallback warning, with focused regression coverage in backend/tests/test_openai_compat_services.py and compileall validation.","dependencies":[{"issue_id":"mirofish-4xn3","depends_on_id":"mirofish-pfbl","type":"discovered-from","created_at":"2026-03-11T23:17:49Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-yt4o","title":"Honor configured interview timeout in report-agent live interviews","description":"Scoped follow-up to upstream issue #62 partial mitigation. backend/app/services/zep_tools.py currently calls SimulationRunner.interview_agents_batch() with a fixed 180-second timeout, which bypasses INTERVIEW_BATCH_TIMEOUT_SECONDS and can still force report-agent live interviews to fail early on slower local or OpenAI-compatible backends. Replace the hardcoded timeout with the configured backend timeout budget, keep behavior locale-safe, add focused regression coverage, and reconcile upstream coverage/triage once landed.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T23:07:41Z","created_by":"Codex","updated_at":"2026-03-11T23:08:50Z","closed_at":"2026-03-11T23:08:50Z","close_reason":"Report-agent live interviews now honor INTERVIEW_BATCH_TIMEOUT_SECONDS instead of a fixed 180-second timeout, with regression coverage in backend/tests/test_zep_tools_i18n.py.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-5q53","title":"Expose merged alias names in zep_tools textual node output","description":"Scoped follow-up to mirofish-975. backend/app/services/zep_tools.py preserves alias_names in NodeInfo.to_dict(), but NodeInfo.to_text() still drops them when tool outputs are rendered back into report/runtime prompts. Include merged alias names in the textual node rendering with locale-aware labels, add focused regression coverage, and reconcile mirofish-975 notes plus upstream coverage once landed.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T22:56:24Z","created_by":"Codex","updated_at":"2026-03-11T22:57:55Z","closed_at":"2026-03-11T22:57:55Z","close_reason":"NodeInfo.to_text now includes merged alias names with locale-aware labels, with focused regression coverage in backend/tests/test_zep_tools_i18n.py and backend-lite validation.","dependencies":[{"issue_id":"mirofish-5q53","depends_on_id":"mirofish-975","type":"discovered-from","created_at":"2026-03-11T22:56:23Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-r653","title":"Expose merged alias names in zep_tools deduplicated node payloads","description":"Scoped follow-up to mirofish-975. zep_tools now collapses obvious duplicate entity aliases across search/detail/statistics/introspection helpers, but most merged NodeInfo/search payloads discard the alias names that were folded together. Preserve alias_names in the repo-native deduplicated node payloads (search results, entity summaries, node detail, raw node introspection) without mutating stored graph data, add focused regression coverage, and reconcile the parent issue notes once landed.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T22:45:41Z","created_by":"Codex","updated_at":"2026-03-11T22:47:09Z","closed_at":"2026-03-11T22:47:09Z","close_reason":"Deduplicated zep_tools node payloads now preserve merged alias_names metadata, with focused regression coverage and backend-lite validation.","dependencies":[{"issue_id":"mirofish-r653","depends_on_id":"mirofish-975","type":"discovered-from","created_at":"2026-03-11T22:45:41Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-nn01","title":"Canonicalize alias node detail reads in zep_tools","description":"Scoped follow-up to mirofish-975. backend/app/services/zep_tools.py get_node_detail() still returns whichever raw Zep node UUID was requested, so report-side callers can still receive an alias-specific payload (for example 美国总统特朗普 instead of the canonical merged 特朗普) even after search/statistics/node-edge helpers already collapse obvious duplicates. Resolve node-detail reads against the same conservative alias group, merge summary/labels/attributes into the canonical node view without mutating stored graph data, add focused regression coverage, and reconcile mirofish-975 once landed.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T22:38:26Z","created_by":"Codex","updated_at":"2026-03-11T22:40:00Z","closed_at":"2026-03-11T22:40:00Z","close_reason":"zep_tools.get_node_detail now canonicalizes alias UUIDs when graph context is available, with focused regression coverage in backend/tests/test_zep_tools_dedup.py and lightweight backend validation.","dependencies":[{"issue_id":"mirofish-nn01","depends_on_id":"mirofish-975","type":"discovered-from","created_at":"2026-03-11T22:38:25Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-trvg","title":"Deduplicate raw zep_tools graph introspection payloads","description":"Scoped follow-up to mirofish-975. backend/app/services/zep_tools.py still returns raw duplicate alias nodes from get_all_nodes() and raw alias-linked edges from get_all_edges(), even though downstream search/statistics/detail helpers already collapse those aliases later. Collapse obvious duplicate alias nodes and remap/deduplicate alias-linked edge payloads in the raw graph introspection helpers, add focused regression coverage, and reconcile mirofish-975 plus upstream coverage notes once landed.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T21:36:42Z","created_by":"Codex","updated_at":"2026-03-11T21:39:04Z","closed_at":"2026-03-11T21:39:04Z","close_reason":"Raw zep_tools graph introspection now collapses obvious alias duplicates in get_all_nodes/get_all_edges, with focused regression coverage in backend/tests/test_zep_tools_dedup.py and lightweight backend validation.","dependencies":[{"issue_id":"mirofish-trvg","depends_on_id":"mirofish-975","type":"discovered-from","created_at":"2026-03-11T21:36:42Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-ujmk","title":"Include alias-linked edges in zep_tools node-edge lookups","description":"Scoped follow-up to mirofish-975. backend/app/services/zep_tools.py get_node_edges() still filters edges by the requested UUID only, so callers can miss relationships attached only to an obvious alias node UUID (for example 美国总统特朗普 instead of 特朗普) even after other repo-native dedup mitigations landed. Resolve the requested node against the conservative alias group, collect edges touching any alias UUID, remap/deduplicate them to the canonical node, add focused regression coverage, and reconcile mirofish-975 plus upstream coverage notes once landed.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T21:15:31Z","created_by":"Codex","updated_at":"2026-03-11T21:17:28Z","closed_at":"2026-03-11T21:17:28Z","close_reason":"zep_tools.get_node_edges now merges alias-linked edges and remaps duplicates, with focused regression coverage in backend/tests/test_zep_tools_dedup.py and lightweight backend validation.","dependencies":[{"issue_id":"mirofish-ujmk","depends_on_id":"mirofish-975","type":"discovered-from","created_at":"2026-03-11T21:15:31Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-utxe","title":"Use a nonzero default lock wait for upstream sync CLI","description":"scripts/sync_upstream_github.py defaults --lock-wait-seconds to 0, so overlapping upstream refreshes fail immediately even though repo automation and evolve-style passes already serialize open/all snapshot writes. Set a small nonzero default wait, add regression coverage for the parser default/help text, and refresh beads export after the fix.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T20:38:18Z","created_by":"Codex","updated_at":"2026-03-11T20:39:15Z","closed_at":"2026-03-11T20:39:15Z","close_reason":"Default upstream sync lock wait now matches repo automation, covered by parser/lock regression tests, and snapshots refreshed successfully.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-ble4","title":"Include alias-linked relations in entity detail responses","description":"Scoped follow-up to mirofish-975. backend/app/services/zep_entity_reader.py get_entity_with_context() still fetches only the requested node UUID and its direct edges, so entity detail responses can miss relationships or related nodes attached only to an obvious alias UUID (for example 美国总统特朗普 instead of 特朗普) even after other repo-native dedup mitigations landed. Rebuild entity-detail context from the full node/edge set for the canonical alias group, remap/deduplicate related nodes and edges conservatively, add focused regression coverage, and reconcile mirofish-975 notes once landed.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T20:03:03Z","created_by":"Codex","updated_at":"2026-03-11T20:04:15Z","closed_at":"2026-03-11T20:04:15Z","close_reason":"Entity detail responses now merge alias-linked relations and related nodes across the canonical alias group, with regression coverage in backend/tests/test_zep_entity_reader.py and lightweight backend validation.","dependencies":[{"issue_id":"mirofish-ble4","depends_on_id":"mirofish-975","type":"discovered-from","created_at":"2026-03-11T20:03:03Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-lxdl","title":"Normalize ontology names for Zep API schema compatibility","description":"Follow upstream PR #152 with a repo-native fix. Normalize ontology generator entity names to PascalCase and relationship names/source_targets to SCREAMING_SNAKE_CASE/PascalCase before graph build submission, add regression coverage, and reconcile upstream triage/coverage notes.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T19:59:18Z","created_by":"Codex","updated_at":"2026-03-11T20:00:45Z","closed_at":"2026-03-11T20:00:45Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-s8ff","title":"Infer active simulation platform for profile/post/comment reads","description":"Repo-native follow-up from upstream PR #151. Frontend Step 2 realtime profile polling still hardcodes reddit, Step 5 always probes both platforms, and the comments route still hardcodes reddit_simulation.db. Let read APIs omit platform so backend inference can choose the only enabled platform, and make comment reads degrade safely for twitter-only simulations. Add focused regression coverage.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T19:51:12Z","created_by":"Codex","updated_at":"2026-03-11T19:53:03Z","closed_at":"2026-03-11T19:53:03Z","close_reason":"Landed repo-native active-platform inference for Step 2 realtime profile reads, Step 5 profile loading, and comments API fallback with focused backend/frontend regression coverage.","dependencies":[{"issue_id":"mirofish-s8ff","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T19:51:11Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-vt2b","title":"Include alias-linked edges in deduplicated entity summaries","description":"Scoped follow-up to mirofish-975. backend/app/services/zep_tools.py currently resolves get_entity_summary() against the canonical deduplicated node, but it fetches related edges by the canonical UUID before alias remapping. If a relationship is attached only to an alias node UUID (for example 美国总统特朗普 instead of 特朗普), the summary can miss that relation even though the entity itself is deduplicated. Rebuild entity-summary edge selection from the full edge set, include edges touching any alias UUID that collapses to the canonical node, remap/deduplicate them, add focused regression coverage, and reconcile mirofish-975 notes once landed.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T19:22:42Z","created_by":"Codex","updated_at":"2026-03-11T19:24:17Z","closed_at":"2026-03-11T19:24:17Z","close_reason":"Entity summaries now include alias-linked relations from the full edge set before canonical remapping, with regression coverage in backend/tests/test_zep_tools_dedup.py and lightweight backend validation.","dependencies":[{"issue_id":"mirofish-vt2b","depends_on_id":"mirofish-975","type":"discovered-from","created_at":"2026-03-11T19:22:41Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-k0ka","title":"Deduplicate duplicate entity aliases in graph statistics and entity summaries","description":"Scoped follow-up to mirofish-975. backend/app/services/zep_tools.py still computes graph statistics from raw node lists and resolves get_entity_summary() by exact-name lookup against undeduplicated nodes, so obvious alias pairs like 美国总统特朗普 vs 特朗普 can still inflate counts and miss merged entity context. Reuse the existing alias-collapse helper to report deduplicated node/type counts and resolve entity summaries against the canonical merged node with focused regression coverage.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T19:14:16Z","created_by":"Codex","updated_at":"2026-03-11T19:15:47Z","closed_at":"2026-03-11T19:15:47Z","close_reason":"Landed repo-native alias-collapse in zep_tools graph statistics and entity-summary helpers with focused regression coverage; validated via backend/tests/test_zep_tools_dedup.py, compileall, and scripts/test_backend_lite.sh.","dependencies":[{"issue_id":"mirofish-k0ka","depends_on_id":"mirofish-975","type":"discovered-from","created_at":"2026-03-11T19:14:15Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-gi5p","title":"Deduplicate duplicate entity aliases in Panorama search output","description":"Scoped follow-up to mirofish-975. backend/app/services/zep_tools.py deduplicated typed entity lists, InsightForge summaries, and QuickSearch payloads, but panorama_search() still exposed raw duplicate edges/facts for obvious alias pairs such as 美国总统特朗普 vs 特朗普 even after node collapse. Remap panorama edges to the canonical node UUID/name, drop duplicate edge rows, and deduplicate repeated active/historical facts with focused regression coverage.","status":"closed","priority":1,"issue_type":"bug","owner":"codex@local","created_at":"2026-03-11T19:11:34Z","created_by":"Codex","updated_at":"2026-03-11T19:11:44Z","closed_at":"2026-03-11T19:11:44Z","close_reason":"Panorama search now remaps duplicate alias edges to the canonical node UUID/name, removes duplicate edge rows, and deduplicates repeated active/historical facts with regression coverage in backend/tests/test_zep_tools_dedup.py. Validated with targeted pytest plus scripts/test_backend_lite.sh.","dependencies":[{"issue_id":"mirofish-gi5p","depends_on_id":"mirofish-975","type":"discovered-from","created_at":"2026-03-11T19:11:34Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-rbpw","title":"Deduplicate duplicate entity aliases in QuickSearch results","description":"Scoped follow-up to mirofish-975. backend/app/services/zep_tools.py currently deduplicates typed entity lists, Panorama output, and InsightForge summaries, but search_graph()/local search still return raw duplicate alias nodes and duplicate summary facts for obvious same-entity variants such as 美国总统特朗普 vs 特朗普. Add the same conservative alias collapse to QuickSearch/search results, remap matching edges where needed, cover it with focused regression tests, and reconcile mirofish-975 notes once landed.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T19:05:56Z","created_by":"Codex","updated_at":"2026-03-11T19:08:45Z","closed_at":"2026-03-11T19:08:45Z","close_reason":"Landed QuickSearch/local-search duplicate-alias collapse in backend/app/services/zep_tools.py with focused regression coverage in backend/tests/test_zep_tools_dedup.py; validated with targeted pytest and scripts/test_backend_lite.sh.","dependencies":[{"issue_id":"mirofish-rbpw","depends_on_id":"mirofish-975","type":"discovered-from","created_at":"2026-03-11T19:05:55Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-hjc6","title":"Track repo-native ontology type naming fix for upstream PR #152","description":"Upstream PR #152 adds Zep ontology type-name normalization, but the branch is not safe to cherry-pick because it rewinds broad local backend/frontend/tooling work. Track a repo-native implementation that only normalizes ontology entity/edge type names and source_targets references to Zep-friendly conventions with focused graph_builder regression coverage.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T18:10:55Z","created_by":"Codex","updated_at":"2026-03-11T18:12:47Z","closed_at":"2026-03-11T18:12:47Z","close_reason":"Mirrored upstream PR #152 into origin/mirror/upstream-pr-152, landed the repo-native ontology naming normalization subset in graph_builder, refreshed upstream coverage snapshots, and passed focused plus lightweight backend validation.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-bc06","title":"Mirror and evaluate upstream PR #151","description":"Upstream refresh at 2026-03-11T17:50Z surfaced a new clean PR #151 fixing Twitter-only default-platform data loss. Mirror the PR head into origin for fork visibility, confirm whether the local branch already covers it repo-natively, refresh upstream summaries/coverage if needed, and close the tracking issue once visibility plus triage are done.","status":"closed","priority":1,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T17:51:11Z","created_by":"Codex","updated_at":"2026-03-11T17:52:53Z","closed_at":"2026-03-11T17:52:53Z","close_reason":"Mirrored upstream PR #151 into origin/mirror/upstream-pr-151, confirmed the local branch already covers its intent repo-natively, and refreshed upstream summaries/coverage so PR #151 now shows mirrored=yes with local coverage landed.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-xhi","title":"Fix hardcoded reddit platform default for Twitter/X simulations","description":"Upstream issue #150 reports silent data loss when a simulation should target Twitter/X but the persisted platform falls back to reddit. Investigate the Step 2/3 configuration path, land a low-risk repo-native fix with regression coverage, and update upstream intake coverage after validation.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T17:39:15Z","created_by":"Codex","updated_at":"2026-03-11T17:43:33Z","closed_at":"2026-03-11T17:43:33Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-5yq","title":"Collapse obvious duplicate entity aliases in Process graph rendering","description":"Scoped follow-up to mirofish-975. Add a conservative alias-collapse step in frontend/src/views/processGraphData.js so obvious same-entity variants (for example title-prefixed duplicates such as 美国总统特朗普 vs 特朗普) render as one node in the Step 1/Process graph view. Keep it repo-native and low-risk by remapping display nodes and edges only, not mutating stored graph data, and cover it with frontend regression tests.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T17:31:55Z","created_by":"Codex","updated_at":"2026-03-11T17:34:24Z","closed_at":"2026-03-11T17:34:24Z","close_reason":"Landed a repo-native frontend graph-view alias-collapse mitigation for issue #145 with regression coverage; broader graph-level dedup still stays under mirofish-975.","dependencies":[{"issue_id":"mirofish-5yq","depends_on_id":"mirofish-975","type":"discovered-from","created_at":"2026-03-11T17:31:55Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-mae","title":"Investigate upstream issue #148 world-agent chat 504","description":"Upstream issue #148 was opened on 2026-03-11 after the latest sync refresh. Report-agent chat works, but chatting with individual world agents from Interactive Tools returns HTTP 504. Investigate the simulation/report interactive backend path, land a low-risk fix if the failure is reproducible from deterministic control-plane behavior, add regression coverage, then update upstream coverage artifacts and close or narrow follow-up work.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T16:50:29Z","created_by":"Codex","updated_at":"2026-03-11T16:53:19Z","closed_at":"2026-03-11T16:53:19Z","close_reason":"Refreshed upstream open/full snapshots, covered upstream issue #148 by hardening interview env liveness against stale env_status.json state, added regression coverage, and validated with targeted pytest plus scripts/test_backend_lite.sh.","dependencies":[{"issue_id":"mirofish-mae","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T16:50:29Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-f7u","title":"Mitigate duplicate entity aliases during simulation entity reads","description":"Scoped follow-up to mirofish-975. Add a conservative duplicate-alias collapse step in ZepEntityReader.filter_defined_entities so obvious same-entity variants (for example title-prefixed names such as 美国总统特朗普 vs 特朗普) do not both flow into simulation/profile generation. Keep the fix repo-native and low-risk by merging only filtered entity views, not mutating stored Zep graph nodes, and cover it with regression tests.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T16:32:43Z","created_by":"Codex","updated_at":"2026-03-11T16:34:33Z","closed_at":"2026-03-11T16:34:33Z","close_reason":"Landed a repo-native duplicate-alias mitigation in ZepEntityReader for simulation/profile inputs with regression coverage; broader graph-level dedup stays tracked under mirofish-975.","dependencies":[{"issue_id":"mirofish-f7u","depends_on_id":"mirofish-975","type":"discovered-from","created_at":"2026-03-11T16:32:43Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-nmu","title":"Refresh upstream intake and land the next safe repo-native fix","description":"Run the upstream GitHub sync for 666ghj/MiroFish, refresh machine-readable issue/PR summaries, mirror visibility into the fork where supported, re-evaluate currently open PRs, and land the next low-risk repo-native fix or review outcome from the refreshed queue.","status":"closed","priority":1,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T16:19:36Z","created_by":"Codex","updated_at":"2026-03-11T16:22:24Z","closed_at":"2026-03-11T16:22:24Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-57t","title":"Mirror upstream issues into fork once fork issues are enabled","description":"The fork currently mirrors PR branches but not upstream issues because GitHub issues were disabled. Enable issues on ivanzud/MiroFish, extend the upstream sync workflow with repo-native fork issue mirroring for actionable upstream issues, and keep the local machine-readable summaries/coverage aligned so future evolve passes do not lose issue visibility.","status":"closed","priority":1,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T15:00:20Z","created_by":"Codex","updated_at":"2026-03-11T15:05:31Z","closed_at":"2026-03-11T15:05:31Z","close_reason":"Enabled fork issues on ivanzud/MiroFish, added idempotent fork issue mirroring to scripts/sync_upstream_github.py, refreshed upstream snapshots, and mirrored all 37 currently open upstream issues into the fork.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-rzr","title":"Refresh upstream intake and land next deterministic i18n fix","description":"Evolve pass on 2026-03-11: refresh upstream issue/PR snapshots for 666ghj/MiroFish, keep fork mirror annotations current, then land the next safe low-risk deterministic localization fix under mirofish-1nh or another clearly reproducible upstream/local gap. Close only after tests pass and tracker/docs are refreshed.","status":"closed","priority":1,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T14:47:32Z","created_by":"Codex","updated_at":"2026-03-11T14:49:40Z","closed_at":"2026-03-11T14:49:40Z","close_reason":"Refreshed upstream open/all snapshots for 666ghj/MiroFish, kept fork PR mirror annotations current, and landed the next low-risk deterministic i18n fix for the parallel simulation runner with validation.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-ejp","title":"Evaluate newly opened upstream PRs #143 and #144","description":"Upstream open PR count increased from 34 to 36 during the current evolve pass. Mirror new upstream PR refs into origin where practical, review the diffs for PR #143 and PR #144 against current local state, land any safe low-risk subset, and update upstream summaries/coverage accordingly.","status":"closed","priority":1,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T14:40:24Z","created_by":"Codex","updated_at":"2026-03-11T14:42:22Z","closed_at":"2026-03-11T14:42:22Z","close_reason":"Mirrored new upstream PRs #143/#144 into origin, landed the safe README alt-text fix repo-natively across all README variants, tracked PR #144 under mirofish-8eg, and refreshed open/full upstream snapshots plus coverage summaries.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-x5u","title":"Harden upstream sync HTTP fallback against transient GitHub 5xx errors","description":"Full-history sync still fails hard when direct HTTP comment fetches return transient GitHub 5xx responses after gh api fallback. Add bounded retry/backoff and reuse stale-cache/degraded behavior so objective #2 (machine-readable upstream intake) remains reliable under normal GitHub instability. Discovered during 2026-03-11 evolve pass while refreshing docs/upstream-all-state.json.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T14:27:01Z","created_by":"Codex","updated_at":"2026-03-11T14:28:45Z","closed_at":"2026-03-11T14:28:45Z","close_reason":"Added bounded retry/backoff for transient GitHub HTTP fallback failures in scripts/sync_upstream_github.py, added regression coverage, and verified the previously failing --state all refresh completes.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-5ur","title":"Bound Step 3 live timeline memory growth","description":"Follow-up from upstream issue #42. The backend no longer resends full history on every poll, but frontend Step 3 still retains an unbounded allActions buffer for the live timeline, and run-status/detail still serializes current-round recent_actions without a hard cap. Land a low-risk bounded-history fix with regression coverage so long simulations do not grow browser/API memory without limit.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T14:06:03Z","created_by":"Codex","updated_at":"2026-03-11T14:07:14Z","closed_at":"2026-03-11T14:07:14Z","close_reason":"Completed","dependencies":[{"issue_id":"mirofish-5ur","depends_on_id":"mirofish-qoo","type":"discovered-from","created_at":"2026-03-11T14:06:03Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-ruj","title":"Refresh upstream intake and land next safe fix","description":"Evolve pass for 2026-03-11: refresh upstream issue/PR snapshots and fork mirror annotations, evaluate newly open upstream items after PR #141 appeared, land the next safe low-risk local fix with tests/docs, and reconcile coverage tracking.","status":"closed","priority":1,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T13:51:45Z","created_by":"Codex","updated_at":"2026-03-11T13:54:21Z","closed_at":"2026-03-11T13:54:21Z","close_reason":"Refreshed upstream snapshots, documented/verified direct OPENAI_BASE_URL support, added regression coverage, and recorded PR #105/#141 triage.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-5h0","title":"Make report tool-output parsers locale-agnostic","description":"Follow-up to the English support sweep. Step4Report still parses interview/search tool output using Chinese-only markers like 问题X, 搜索查询, 相关事实, and the Chinese no-response placeholder. Add locale-agnostic parser helpers so English report/interview outputs render correctly without regressing existing Chinese output, and cover the parsing behavior with frontend tests.","status":"closed","priority":1,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T13:07:28Z","created_by":"Codex","updated_at":"2026-03-11T13:11:14Z","closed_at":"2026-03-11T13:11:14Z","close_reason":"Step4Report now uses locale-agnostic parser helpers for interview/search tool output, including English headings and no-reply placeholders, with frontend regression tests and a passing production build.","dependencies":[{"issue_id":"mirofish-5h0","depends_on_id":"mirofish-ohy","type":"discovered-from","created_at":"2026-03-11T13:07:27Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-ziz","title":"Refresh upstream intake and land next low-risk fix","description":"Evolve pass for 2026-03-11: refresh 666ghj/MiroFish issue/PR snapshots plus fork mirror annotations, review open PR/issue queue against current branch state, and land the next safe actionable low-risk fix with tests/docs if feasible.","status":"closed","priority":1,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T12:59:42Z","created_by":"Codex","updated_at":"2026-03-11T13:01:23Z","closed_at":"2026-03-11T13:01:23Z","close_reason":"Completed: refreshed upstream issue/PR snapshots and hardened GitHub sync rate-limit fallback","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-t8x","title":"Handle Zep retry-after throttling during graph builds","description":"Follow-up to upstream issues #60 and #75. Current graph-builder retries transient Zep failures, but review this branch's retry path for explicit 429 retry-after handling and user-visible progress so rate limits do not fail or stall prematurely during graph creation, ontology setup, or batch uploads. Land only a low-risk bounded retry improvement with regression tests.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T12:47:03Z","created_by":"Codex","updated_at":"2026-03-11T12:49:10Z","closed_at":"2026-03-11T12:49:10Z","close_reason":"Implemented bounded Retry-After-aware Zep graph-build retries with regression tests and refreshed upstream coverage snapshots.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-e5a","title":"Refresh upstream intake and land next low-risk fix","description":"Refresh machine-readable upstream issue/PR snapshots from 666ghj/MiroFish, keep fork PR mirrors current, re-evaluate open PR safety, and land the highest-signal reproducible low-risk issue or docs/config improvement found in this pass.","status":"closed","priority":1,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T12:33:49Z","created_by":"Codex","updated_at":"2026-03-11T12:37:07Z","closed_at":"2026-03-11T12:37:07Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-bio","title":"Harden graph-build Zep auth/non-JSON failure reporting","description":"Upstream issue #139 shows graph build failures surfacing a raw traceback from zep_cloud raw_client when Zep returns a non-JSON 401/HTML/plaintext response. Normalize these failures into deterministic user-facing auth/config errors instead of leaking tracebacks, add regression coverage around embedded traceback/non-JSON auth messages, and refresh triage after landing.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T11:33:04Z","created_by":"Codex","updated_at":"2026-03-11T11:33:54Z","closed_at":"2026-03-11T11:33:54Z","close_reason":"Follow-up hardening completed: graph-build error normalization now strips embedded traceback noise from Zep SDK failures while still classifying 401/403/auth messages into actionable user-facing errors; added regression coverage in backend/tests/test_graph_builder.py and updated triage notes.","dependencies":[{"issue_id":"mirofish-bio","depends_on_id":"mirofish-lms","type":"discovered-from","created_at":"2026-03-11T11:33:04Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-1n5","title":"Resume existing Step 3 simulation runs before force restart","description":"Frontend Step3Simulation always calls /api/simulation/start with force=true on mount, which clears existing run logs and prevents users from reattaching to an in-progress or failed simulation after refresh/navigation. Update the Step 3 flow to inspect current run status first, resume polling existing runs/details when present, preserve completed timelines, and only force restart from an explicit restart action. Mirrors upstream issue #9 ('可以设置中断重新加载吗') as a low-risk UX fix.","status":"closed","priority":1,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T11:18:27Z","created_by":"Codex","updated_at":"2026-03-11T11:20:29Z","closed_at":"2026-03-11T11:20:29Z","close_reason":"Step3Simulation now probes existing run status/timeline before auto-starting, reattaches to running/completed/failed runs, and only clears logs through an explicit restart action. Validated with frontend tests and production build.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-o7b","title":"Surface actionable file-upload parse errors","description":"Upstream issue #64 still reports generic 500s during document upload. The ontology upload endpoint currently lets FileParser/processing exceptions bubble into a generic handle_api_exception path, so users cannot distinguish unsupported/corrupt files, missing PDF parser deps, empty extracted text, or server-side parse failures. Add a low-risk API error seam that fails the request with structured per-file diagnostics and regression tests.","notes":"2026-03-11: /api/graph/ontology/generate now classifies unsupported extensions, file-parse failures, and empty extracted text as 400-level document-processing errors with per-file diagnostics instead of bubbling a generic 500. Added backend/tests/test_graph_upload_api.py and extended scripts/test_backend_lite.sh; lightweight backend suite passes.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T10:30:21Z","created_by":"Codex","updated_at":"2026-03-11T10:32:29Z","closed_at":"2026-03-11T10:32:29Z","close_reason":"Upload-time parse/validation failures now return structured 400 diagnostics with regression coverage instead of a generic 500.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-u6e","title":"Harden upstream sync rate-limit fallback during detail hydration","description":"The current scripts/sync_upstream_github.py fallback to cached snapshots does not protect the full refresh path when GitHub rate limiting happens during issue comment or PR detail hydration. Repro on 2026-03-11: open-state refresh started, then failed with RuntimeError: GitHub API rate limit exceeded while hydrating pull details, even though recent matching docs/upstream-*.json snapshots already existed. Make the sync path degrade to the freshest matching local snapshot or partial cached details instead of aborting intake.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T10:17:54Z","created_by":"Codex","updated_at":"2026-03-11T10:19:23Z","closed_at":"2026-03-11T10:19:23Z","close_reason":"Applied fresh-cache fallback to the entire upstream sync pipeline, added hydration-rate-limit regression coverage, and refreshed both open/all upstream snapshots successfully.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-1qo","title":"Harden upstream sync snapshots and single-flight execution","description":"The current evolve pass exposed two intake issues in scripts/sync_upstream_github.py: older tooling still looks for generated_at while the script now writes captured_at, and running open/all refreshes concurrently increases contention without any guardrail. Add backward-compatible snapshot metadata, prevent overlapping sync runs with a lock, cover the behavior with tests, then refresh tracker/export state.","status":"closed","priority":1,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T09:42:49Z","created_by":"Codex","updated_at":"2026-03-11T09:44:35Z","closed_at":"2026-03-11T09:44:35Z","close_reason":"Added backward-compatible generated_at snapshot metadata, summary timestamps sourced from capture time, and repo-scoped sync locking with regression tests.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-0kl","title":"Replace optional OASIS runtime to remove vulnerable unstructured chain","description":"The default backend install no longer pulls camel-oasis or unstructured after isolating the OASIS stack behind the simulation extra, but the optional simulation runtime still depends on camel-oasis==0.2.5 and therefore transitively installs unstructured==0.13.7. Follow up by replacing/upgrading/vendoring the OASIS runtime so Step 3/5 can be re-enabled without the vulnerable dependency chain.","status":"closed","priority":1,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T09:27:10Z","created_by":"Codex","updated_at":"2026-03-11T09:34:46Z","closed_at":"2026-03-11T09:34:46Z","close_reason":"Vendored the upstream oasis runtime under backend/oasis, removed camel-oasis from the optional simulation manifests, refreshed backend/uv.lock so camel-oasis and unstructured are gone, added regression coverage, and documented the remaining Python 3.13 tiktoken/Rust blocker separately.","dependencies":[{"issue_id":"mirofish-0kl","depends_on_id":"mirofish-5as","type":"discovered-from","created_at":"2026-03-11T09:27:10Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-qmv","title":"Reduce Step 5 interview timeout failures on slow/local backends","description":"Open issues #43 and #58 still point to deep-interaction / IPC timeout failures on slower local backends. Make interview wait windows configurable, send timeout overrides from the Step 5 UI, document the knobs, validate, and then close the issue if the change lands cleanly.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T09:22:42Z","created_by":"Codex","updated_at":"2026-03-11T09:22:55Z","closed_at":"2026-03-11T09:22:55Z","close_reason":"Configurable backend interview timeouts, Step 5 timeout overrides, docs, and validation landed.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-3fb","title":"Fix Step 5 platform-aware agent interaction","description":"Step5Interaction currently only loads reddit profiles and sends interviews without platform metadata. This breaks twitter-only simulations and makes dual-platform agent chat/surveys ambiguous. Normalize profiles across available platforms, preserve platform on selection, and target interviews/surveys accordingly with regression tests.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T09:01:44Z","created_by":"Codex","updated_at":"2026-03-11T09:03:49Z","closed_at":"2026-03-11T09:03:49Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-c3h","title":"Handle string ontology attributes in graph builder","description":"Upstream issue #135 shows GraphBuilder.set_ontology crashing with TypeError when ontology attribute entries are strings instead of {name,...} objects. Harden ontology normalization to accept string attribute definitions the same way entity/edge items were sanitized, add regression coverage, and record the upstream issue in local triage notes.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T08:57:13Z","created_by":"Codex","updated_at":"2026-03-11T08:58:38Z","closed_at":"2026-03-11T08:58:38Z","close_reason":"Completed","dependencies":[{"issue_id":"mirofish-c3h","depends_on_id":"mirofish-ued","type":"discovered-from","created_at":"2026-03-11T08:57:13Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-ued","title":"Restore sync_upstream_github legacy output flags","description":"scripts/sync_upstream_github.py currently requires --output and --summary, but existing triage docs and prior automation still use --json-out and --md-out. Add backwards-compatible aliases, cover them with tests, and refresh tracker artifacts so upstream intake remains resumable across evolve passes.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T08:56:29Z","created_by":"Codex","updated_at":"2026-03-11T08:58:38Z","closed_at":"2026-03-11T08:58:38Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-bdw","title":"Bound upstream GitHub sync calls with timeouts","description":"scripts/sync_upstream_github.py can hang indefinitely during upstream intake because gh api subprocess calls and urllib HTTP requests do not set timeouts. Add bounded request/subprocess timeouts, make them configurable, and cover the behavior with unit tests so evolve passes can reliably refresh machine-readable upstream snapshots.","status":"closed","priority":1,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T08:46:59Z","created_by":"Codex","updated_at":"2026-03-11T08:49:46Z","closed_at":"2026-03-11T08:49:46Z","close_reason":"Added bounded gh/http request timeouts to scripts/sync_upstream_github.py, covered them with unit tests, and verified both open/all upstream snapshot refreshes complete successfully with --timeout 15.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-4td","title":"Reduce simulation status polling payload growth","description":"Upstream issue #42 reports large memory use during Step 3 simulation runs. The current /api/simulation/\u003cid\u003e/run-status/detail endpoint returns the full action history on every poll, and the frontend polls it every 3s. Narrow this to bounded initial history plus incremental action fetches, add regression coverage, and document/triage the upstream linkage.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T08:35:54Z","created_by":"Codex","updated_at":"2026-03-11T08:38:27Z","closed_at":"2026-03-11T08:38:27Z","close_reason":"Step 3 now fetches a bounded recent action window on first load and incremental actions on subsequent polls via since timestamp, with lightweight regression coverage and frontend validation/build passing.","dependencies":[{"issue_id":"mirofish-4td","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T08:35:54Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-5uh","title":"Harden upstream GitHub sync against transient gh api PR detail failures","description":"During this pass, scripts/sync_upstream_github.py failed mid-refresh while hydrating /pulls/\u003cnumber\u003e via gh api, aborting the entire snapshot update. Add bounded retries and a fallback path so transient GitHub CLI/API failures do not leave upstream intake stale.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T08:20:24Z","created_by":"Codex","updated_at":"2026-03-11T08:22:50Z","closed_at":"2026-03-11T08:22:50Z","close_reason":"Added gh api retry/fallback handling plus tests, and upstream open/all snapshot refreshes complete again.","dependencies":[{"issue_id":"mirofish-5uh","depends_on_id":"mirofish-19i","type":"discovered-from","created_at":"2026-03-11T08:20:23Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-ak9","title":"Fix frontend API fallback for dual-port deployments","description":"Upstream issue 666ghj/MiroFish#133 shows the current frontend base URL fallback breaks the documented/default topology where the frontend is served on port 3000 and the backend API is on 5001. The client currently falls back to window.location.origin, which sends production-style frontend requests to the frontend host instead of the backend port when no reverse proxy is present. Patch the fallback to preserve same-origin setups while auto-targeting port 5001 for the documented local/docker layout, then update docs and validate with a frontend build.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T07:50:30Z","created_by":"Codex","updated_at":"2026-03-11T07:52:28Z","closed_at":"2026-03-11T07:52:28Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-api","title":"Fallback when JSON mode is unsupported in OpenAI-compatible generators","description":"Objective 7 / upstream issue #32 follow-up. OasisProfileGenerator and SimulationConfigGenerator still required response_format=json_object, which breaks some OpenAI-compatible backends even though base_url/api_key setup is otherwise valid. Add compatibility fallback without broad refactors, cover it with regression tests, and refresh triage/docs.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T07:32:51Z","created_by":"Codex","updated_at":"2026-03-11T07:33:49Z","closed_at":"2026-03-11T07:33:49Z","close_reason":"Completed: persona/config generators now retry without response_format=json_object on compatible backends, regression tests added, and triage updated.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-tfl","title":"Honor OPENAI_API_BASE_URL in standalone simulation runners","description":"Objective 7 follow-up: backend config already accepts OPENAI_API_BASE_URL, but backend/scripts/run_parallel_simulation.py, run_reddit_simulation.py, and run_twitter_simulation.py only read LLM_BASE_URL / OPENAI_BASE_URL on input before exporting OPENAI_API_BASE_URL. This breaks direct Codex/OpenAI-compatible backend setup for standalone runs when users only provide OPENAI_API_BASE_URL. Add alias support and regression tests.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T07:28:58Z","created_by":"Codex","updated_at":"2026-03-11T07:30:19Z","closed_at":"2026-03-11T07:30:19Z","close_reason":"Completed: standalone simulation runners now accept OPENAI_API_BASE_URL via a shared helper with regression coverage.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-5as","title":"Unblock unstructured CVE remediation from camel-oasis pin","description":"Upstream PR #82 cannot be landed as a real fix because backend/camel-oasis==0.2.5 transitively requires unstructured==0.13.7. A coordinated remediation needs either a compatible camel-oasis upgrade, a vendor patch, or removal/isolation of the vulnerable unstructured path before backend/uv.lock can move to unstructured==0.18.18.","status":"closed","priority":1,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T07:21:33Z","created_by":"Codex","updated_at":"2026-03-11T09:27:28Z","closed_at":"2026-03-11T09:27:28Z","close_reason":"Isolated the OASIS runtime behind an optional simulation extra so the default backend install no longer pulls camel-oasis or unstructured; added runtime/docs/test coverage and opened follow-up mirofish-0kl for replacing the optional vulnerable simulation stack.","dependencies":[{"issue_id":"mirofish-5as","depends_on_id":"mirofish-6yh","type":"discovered-from","created_at":"2026-03-11T07:21:33Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-4yr","title":"Fallback to gh auth for upstream sync","description":"scripts/sync_upstream_github.py currently hard-fails on anonymous GitHub API rate limits even when the GitHub CLI is authenticated and usable in this environment. Add a safe fallback to use gh api for issue/PR intake when GITHUB_TOKEN/GH_TOKEN is absent, cover it with unit tests, then refresh the upstream summary artifacts.","status":"closed","priority":1,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T07:20:22Z","created_by":"Codex","updated_at":"2026-03-11T07:21:47Z","closed_at":"2026-03-11T07:21:47Z","close_reason":"Completed: sync script now falls back to authenticated gh api when env tokens are absent, tests cover the fallback, and upstream open/all snapshots were refreshed successfully.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-gba","title":"Allow retry from failed report generation view","description":"Address upstream issue #84 by teaching the report-generation UI to detect a failed report, surface the backend error, and trigger a force_regenerate retry from Step4Report instead of leaving the user stranded on a dead report page.","status":"closed","priority":1,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T07:16:40Z","created_by":"Codex","updated_at":"2026-03-11T07:18:46Z","closed_at":"2026-03-11T07:18:46Z","close_reason":"Completed: Step4Report now detects failed report status, surfaces the backend error, and lets the user force-regenerate from the failed report view.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-mfi","title":"Accept OPENAI_API_BASE_URL alias in backend config","description":"Objective 7 follow-up: backend config should honor OPENAI_API_BASE_URL in addition to LLM_BASE_URL and OPENAI_BASE_URL so Codex/OpenAI-compatible tooling and standalone runners behave consistently. Add regression coverage and include it in the lightweight backend test path.","status":"closed","priority":1,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T07:07:22Z","created_by":"Codex","updated_at":"2026-03-11T07:07:40Z","closed_at":"2026-03-11T07:07:40Z","close_reason":"Implemented OPENAI_API_BASE_URL alias support in backend config, added regression coverage, and included the test in npm run test:backend:lite.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-dpy","title":"Land safe subset of upstream PR #105: hide traceback details from API responses","description":"Implement the low-risk portion of 666ghj/MiroFish#105 only: add a shared backend error-response helper, remove traceback leakage from API JSON responses while preserving debug-mode traceback support, add targeted unit tests, and update triage. Leave CORS/default DEBUG/SECRET_KEY behavior as a separate follow-up because those defaults can change deployment behavior.","status":"closed","priority":1,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T06:58:49Z","created_by":"Codex","updated_at":"2026-03-11T07:01:55Z","closed_at":"2026-03-11T07:01:55Z","close_reason":"Landed the safe subset of upstream PR #105: shared API error helper, hidden traceback details unless DEBUG is enabled, file-parser fallback diagnostics, targeted tests, and updated triage.","dependencies":[{"issue_id":"mirofish-dpy","depends_on_id":"mirofish-jyo","type":"discovered-from","created_at":"2026-03-11T06:58:48Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-crl","title":"Land low-risk upstream fixes #73 and #74","description":"Evaluate and land the remaining small, reproducible upstream hardening fixes: sanitize malformed ontology entity/edge items from PR #73 and replace bare except clauses with except Exception from PR #74. Add targeted regression coverage for ontology validation and run lightweight backend validation.","status":"closed","priority":1,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T06:50:58Z","created_by":"Codex","updated_at":"2026-03-11T06:51:54Z","closed_at":"2026-03-11T06:51:54Z","close_reason":"Landed upstream PR #73 and #74 equivalents locally, added ontology regression coverage, refreshed upstream snapshots, and re-ran lightweight backend validation.","dependencies":[{"issue_id":"mirofish-crl","depends_on_id":"mirofish-49x","type":"discovered-from","created_at":"2026-03-11T06:50:57Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-o6p","title":"Capture full upstream GitHub issue/PR state locally","description":"Extend scripts/sync_upstream_github.py so local artifacts include all upstream issues and pull requests, not just open items. Preserve compact machine-readable summaries, support pagination, and refresh docs/upstream-open-state.json plus human-readable summaries so evolve passes can triage from complete upstream state.","status":"closed","priority":1,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T06:42:55Z","created_by":"Codex","updated_at":"2026-03-11T06:45:02Z","closed_at":"2026-03-11T06:45:02Z","close_reason":"Added paginated authenticated upstream sync support, generated full-state issue/PR artifacts, added unit tests, and landed safe docs-only PRs #130 and #132.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-shn","title":"Land safe subset of upstream PR #129 token overflow handling","description":"Evaluate 666ghj/MiroFish#129 and land the low-risk subset: configurable LLM max tokens plus context-overflow retry trimming and report-agent message pruning, with targeted llm_client regression tests and docs updates as needed.","status":"closed","priority":1,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T06:38:30Z","created_by":"Codex","updated_at":"2026-03-11T06:41:24Z","closed_at":"2026-03-11T06:41:24Z","close_reason":"Landed safe subset of PR #129, mirrored upstream branch, refreshed upstream snapshot, added targeted tests, and fixed direct OPENAI_* alias support in simulation runners","dependencies":[{"issue_id":"mirofish-shn","depends_on_id":"mirofish-49x","type":"discovered-from","created_at":"2026-03-11T06:38:30Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-0n7","title":"Land upstream PR #81 configurable frontend API timeout","description":"Cherry-pick or reimplement 666ghj/MiroFish#81, which adds VITE_API_TIMEOUT support for slow local OpenAI-compatible models like Ollama. This is a narrow, low-risk fix for issue #58 and fits the current safe-first merge pass.","status":"closed","priority":1,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T06:35:53Z","created_by":"Codex","updated_at":"2026-03-11T06:36:37Z","closed_at":"2026-03-11T06:36:37Z","close_reason":"Implemented configurable frontend timeout and documented VITE_API_TIMEOUT","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-2cp","title":"Evaluate upstream PR #131 retry logic for Zep transient failures","description":"Review 666ghj/MiroFish#131 branch feat/zep-retry-mechanism as the next likely bug-fix candidate. Validate whether the retry/backoff logic is safe to cherry-pick or should be reimplemented more narrowly, especially for issues #60 and #75 around Zep connectivity and rate limits.","status":"closed","priority":1,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T06:34:07Z","created_by":"Codex","updated_at":"2026-03-11T06:48:22Z","closed_at":"2026-03-11T06:48:22Z","close_reason":"Implemented a narrower safe subset of upstream PR #131: transient-only retry/backoff for Zep graph creation, ontology setup, and batch uploads, with targeted backend regression tests.","dependencies":[{"issue_id":"mirofish-2cp","depends_on_id":"mirofish-49x","type":"discovered-from","created_at":"2026-03-11T06:34:06Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-jyo","title":"Review upstream PR #105 security hardening sweep","description":"Review 666ghj/MiroFish#105 as a separate pass. It changes config defaults, CORS behavior, error handling, and traceback exposure across multiple APIs, so it should not be bundled into low-risk cherry-pick cycles without targeted validation.","status":"closed","priority":1,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T06:34:07Z","created_by":"Codex","updated_at":"2026-03-11T07:01:55Z","closed_at":"2026-03-11T07:01:55Z","close_reason":"Reviewed upstream PR #105, landed the safe low-risk error-handling subset locally, and split the remaining behavior-changing defaults/CORS/SECRET_KEY decisions into follow-up issue mirofish-58w.","dependencies":[{"issue_id":"mirofish-jyo","depends_on_id":"mirofish-49x","type":"discovered-from","created_at":"2026-03-11T06:34:06Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-49x","title":"Refresh upstream GitHub snapshot and land next safe PR","description":"Evolve pass: sync all open issues/PRs from 666ghj/MiroFish into docs/, evaluate safe open PRs not yet landed on this branch, cherry-pick or implement the smallest safe fix, validate, and push progress.","status":"closed","priority":1,"issue_type":"task","owner":"codex@local","created_at":"2026-03-11T06:33:01Z","created_by":"Codex","updated_at":"2026-03-11T06:34:17Z","closed_at":"2026-03-11T06:34:17Z","close_reason":"Refreshed upstream issue/PR snapshot, landed PR #125 and PR #116 on this branch, queued follow-up review items, and recorded backend validation blocker.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-18v","title":"2026-03-11 upstream sync and backend compatibility","description":"Track upstream intake from 666ghj/MiroFish, mirror selected work into the fork, and land safe compatibility fixes incrementally.","status":"closed","priority":1,"issue_type":"epic","owner":"codex@local","created_at":"2026-03-11T06:28:08Z","created_by":"Codex","updated_at":"2026-03-11T06:29:52Z","closed_at":"2026-03-11T06:29:52Z","close_reason":"Initial upstream sync and backend compatibility cycle completed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-18v.1","title":"Snapshot upstream issues and pull requests","description":"Refresh a machine-readable snapshot of open upstream issues and PRs, then summarize the active backlog for the next evolve pass.","status":"closed","priority":1,"issue_type":"task","owner":"codex@local","created_at":"2026-03-11T06:28:08Z","created_by":"Codex","updated_at":"2026-03-11T06:29:51Z","closed_at":"2026-03-11T06:29:51Z","close_reason":"Generated docs/upstream-open-summary.md and local upstream snapshot via scripts/sync_upstream_github.py","external_ref":"gh:666ghj/MiroFish","dependencies":[{"issue_id":"mirofish-18v.1","depends_on_id":"mirofish-18v","type":"parent-child","created_at":"2026-03-11T06:28:07Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-18v.2","title":"Review and land safe upstream PRs #115/#122/#124/#127","description":"Prefer metadata/config/parsing fixes first. Cherry-pick directly where safe and supersede overlapping changes with a consolidated local patch where necessary.","status":"closed","priority":1,"issue_type":"task","owner":"codex@local","created_at":"2026-03-11T06:28:08Z","created_by":"Codex","updated_at":"2026-03-11T06:29:51Z","closed_at":"2026-03-11T06:29:51Z","close_reason":"Cherry-picked upstream PR #115 and consolidated safe llm_client fixes from PRs #122/#124/#127","dependencies":[{"issue_id":"mirofish-18v.2","depends_on_id":"mirofish-18v","type":"parent-child","created_at":"2026-03-11T06:28:08Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-18v.4","title":"Support standard OPENAI_* env aliases","description":"Allow MiroFish to use OpenAI/Codex-compatible backends directly via OPENAI_API_KEY, OPENAI_BASE_URL, and OPENAI_MODEL in addition to LLM_* variables.","status":"closed","priority":1,"issue_type":"task","owner":"codex@local","created_at":"2026-03-11T06:28:08Z","created_by":"Codex","updated_at":"2026-03-11T06:29:51Z","closed_at":"2026-03-11T06:29:51Z","close_reason":"Config now accepts OPENAI_* aliases and docs describe direct OpenAI-compatible backend support","dependencies":[{"issue_id":"mirofish-18v.4","depends_on_id":"mirofish-18v","type":"parent-child","created_at":"2026-03-11T06:28:08Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-hct2","title":"Add translated README parity for forecast verification workflow","description":"Follow-up to upstream issue #158 / parent mirofish-gytl. README.md and README-EN.md document the manual forecast verification trail, but README-RU.md, README-KO.md, and README-JA.md still omit that guidance. Add localized docs parity around preserving report_id/simulation_id, exporting Markdown evidence, and the current manual-vs-automatic verification boundary, then reconcile the parent issue and upstream coverage artifacts.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T04:01:34Z","created_by":"Codex","updated_at":"2026-03-12T04:03:33Z","closed_at":"2026-03-12T04:03:33Z","close_reason":"Completed: added forecast verification workflow parity to README-RU.md, README-KO.md, and README-JA.md; refreshed upstream coverage artifacts.","dependencies":[{"issue_id":"mirofish-hct2","depends_on_id":"mirofish-gytl","type":"discovered-from","created_at":"2026-03-12T04:01:33Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-ilde","title":"Embed a manual verification checklist in exported reports","description":"Follow-up to upstream issue #158 / parent mirofish-gytl. Exported Markdown already embeds report/simulation references, but the artifact still lacks a clear verification checklist and stable local report paths for offline review. Add a localized checklist + storage-path hints directly to the generated report header so saved evidence remains actionable outside the UI, with focused backend regression coverage.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T03:57:31Z","created_by":"Codex","updated_at":"2026-03-12T03:59:00Z","closed_at":"2026-03-12T03:59:00Z","close_reason":"Completed: exported reports now embed stable storage paths plus a localized manual verification checklist, with focused backend regression coverage.","dependencies":[{"issue_id":"mirofish-ilde","depends_on_id":"mirofish-gytl","type":"discovered-from","created_at":"2026-03-12T03:57:30Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-zsbj","title":"Make report download API use verification-friendly filenames","description":"Follow-up to upstream issue #158 / parent mirofish-gytl. The frontend now suggests stable report+simulation-aware Markdown download filenames, but backend GET /api/report/\u003creport_id\u003e/download still sends plain \u003creport_id\u003e.md in Content-Disposition. Make the backend serve the same verification-friendly filename so saved evidence keeps both IDs even when JS download hints are ignored, with focused backend regression coverage.","status":"closed","priority":2,"issue_type":"task","owner":"codex@local","created_at":"2026-03-12T03:54:05Z","created_by":"Codex","updated_at":"2026-03-12T03:54:37Z","closed_at":"2026-03-12T03:54:37Z","close_reason":"Backend report downloads now use verification-friendly report+simulation filenames; covered by backend/tests/test_report_api_i18n.py.","dependencies":[{"issue_id":"mirofish-zsbj","depends_on_id":"mirofish-gytl","type":"discovered-from","created_at":"2026-03-12T03:54:04Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-ukcn","title":"Add copyable forecast verification bundle","description":"Follow-up to upstream issue #158 / parent mirofish-gytl. Step 4 and history already expose report_id and simulation_id separately, but the manual forecast-verification workflow still makes users copy multiple fields by hand. Add a low-risk frontend affordance that copies a single structured verification bundle containing the stable report/simulation references (and any already-available timestamp metadata), with focused regression coverage and parent-note reconciliation after landing.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T03:49:37Z","created_by":"Codex","updated_at":"2026-03-12T03:51:43Z","closed_at":"2026-03-12T03:51:43Z","close_reason":"Added copyable verification bundle actions to Step 4 and history, covered by frontend/tests/verificationBundle.test.mjs and a passing frontend test/build refresh.","dependencies":[{"issue_id":"mirofish-ukcn","depends_on_id":"mirofish-gytl","type":"discovered-from","created_at":"2026-03-12T03:49:36Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-e2c0","title":"Restore direct Step 4 report export with verification-friendly filenames","description":"While continuing upstream issue #158 verification UX work, current HEAD exposes Markdown export only from the history modal. Step4Report.vue no longer shows a direct export control despite earlier tracker notes, and exported filenames still omit the paired simulation_id that users need when preserving evidence for later verification. Restore direct Step 4 export and carry a stable report+simulation-based filename through both export surfaces.","status":"closed","priority":2,"issue_type":"task","owner":"codex@local","created_at":"2026-03-12T03:45:55Z","created_by":"Codex","updated_at":"2026-03-12T03:47:16Z","closed_at":"2026-03-12T03:47:16Z","close_reason":"Restored direct Step 4 Markdown export and added verification-friendly report+simulation filenames.","dependencies":[{"issue_id":"mirofish-e2c0","depends_on_id":"mirofish-gytl","type":"discovered-from","created_at":"2026-03-12T03:45:54Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-fze7","title":"Expose direct-backend next-step guidance in backend diagnostics","description":"When the backend status detects a direct OPENAI_* / Codex-compatible LLM path but ZEP_API_KEY is missing, the frontend diagnostics currently stop at capability status. Add explicit next-step guidance so users understand the viable repo-native path is Step 2/3 plus simulation-backed Step 5, while Step 1 graph build and Step 4 graph-backed report tools remain blocked until a non-Zep backend exists.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T03:38:51Z","created_by":"Codex","updated_at":"2026-03-12T03:40:13Z","closed_at":"2026-03-12T03:40:13Z","close_reason":"Implemented explicit direct-backend next-step guidance in frontend diagnostics and validated with frontend test/build.","dependencies":[{"issue_id":"mirofish-fze7","depends_on_id":"mirofish-gd5z","type":"discovered-from","created_at":"2026-03-12T03:38:51Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-7aht","title":"Add translated README parity for direct Codex/OpenAI capability matrix docs","description":"README-KO.md, README-JA.md, and README-RU.md already document the direct OPENAI_*/Codex-compatible backend path, but they still miss the newer summary.capabilities explanation that separates direct_llm readiness from Zep-gated Step 1/Step 4 workflow requirements. Land the same backend:local/config-status capability-matrix guidance that already exists in README.md and README-EN.md so objective #7 stays visible across translated docs.","notes":"2026-03-12: Added direct Codex/OpenAI-compatible backend capability-matrix docs parity to README-RU.md, README-KO.md, and README-JA.md. The translated setup guides now explain summary.capabilities from npm run backend:local / /api/graph/config/status so users can distinguish the direct_llm path from the Zep-gated Step 1 / Step 4 workflow and the existing-simulation Step 5 interaction path. Revalidated with env -i PATH=\"$PATH\" HOME=\"$HOME\" TERM=\"$TERM\" OPENAI_API_KEY=codex-test-key OPENAI_API_BASE_URL=https://codex.example.test/v1 OPENAI_MODEL=gpt-4.1-mini ZEP_API_KEY=zep-test-key SECRET_KEY=test-secret npm run check:backend-config -- --compact, and refreshed the upstream machine-readable snapshots with ./scripts/refresh_upstream_snapshots.sh --stale-cache-hours 24.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T03:35:59Z","created_by":"Codex","updated_at":"2026-03-12T03:37:04Z","closed_at":"2026-03-12T03:37:04Z","close_reason":"Completed","dependencies":[{"issue_id":"mirofish-7aht","depends_on_id":"mirofish-pfbl","type":"discovered-from","created_at":"2026-03-12T03:35:58Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-zx6p","title":"Track self-hosted graph backend request from upstream issue #159","description":"Upstream issue #159 requests a self-hosted or non-Zep graph backend because Zep episode quotas remain too expensive for deeper analysis. This overlaps the existing non-Zep backend/product gap already tracked for upstream issues #55, #76, #106, and #156. Create a child bead so the new upstream request is preserved explicitly and can be reconciled back into the broader backend-abstraction work if a repo-native path becomes feasible.","status":"open","priority":2,"issue_type":"task","owner":"codex@local","created_at":"2026-03-12T03:21:24Z","created_by":"Codex","updated_at":"2026-03-12T03:21:24Z","dependencies":[{"issue_id":"mirofish-zx6p","depends_on_id":"mirofish-8eg","type":"discovered-from","created_at":"2026-03-12T03:21:23Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-a8io","title":"Add Step 5 history replay shortcut without requiring report_id","description":"The history modal already exposes Step 1-4 playback, but the repo now supports Step 5 via either report_id or simulation_id through frontend/src/components/interactionRoute.js. Add a history-modal action that opens the correct Step 5 route for saved runs even when report generation never completed, so users blocked on Zep/report generation can still reopen interaction from history.","notes":"2026-03-12: HistoryDatabase.vue now exposes a Step 5 button in the saved-run modal that reuses interactionRoute.js to open either the report-backed or simulation-only interaction workspace, so runs blocked on report generation can still reopen Step 5 directly from history. Added zh/en history locale copy updates plus frontend/tests/historyInteractionRoute.test.mjs coverage. Validated with npm --prefix frontend test and npm --prefix frontend run build.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T03:15:24Z","created_by":"Codex","updated_at":"2026-03-12T03:17:27Z","closed_at":"2026-03-12T03:17:27Z","close_reason":"Completed","dependencies":[{"issue_id":"mirofish-a8io","depends_on_id":"mirofish-gd5z","type":"discovered-from","created_at":"2026-03-12T03:15:24Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-3is2","title":"Allow Step 5 interaction without Step 4 report route","description":"Narrow follow-up for upstream issue #156 / parent mirofish-gd5z. The frontend already supports simulation-backed interview/profile operations in Step 5, but routing still requires /interaction/:reportId and Step 3 only offers report generation. Add a simulation-only Step 5 route and targeted CTA paths so users on direct OpenAI-compatible backends can continue to role interaction even when graph-backed Step 4 report generation is blocked by missing ZEP_API_KEY.","status":"closed","priority":2,"issue_type":"feature","owner":"codex@local","created_at":"2026-03-12T03:08:31Z","created_by":"Codex","updated_at":"2026-03-12T03:13:04Z","closed_at":"2026-03-12T03:13:04Z","close_reason":"Completed","dependencies":[{"issue_id":"mirofish-3is2","depends_on_id":"mirofish-gd5z","type":"discovered-from","created_at":"2026-03-12T03:08:30Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-31if","title":"Expose Step 3 recovery CTA in Step 5 when interview env is offline","description":"Step 5 already shows an offline interview-environment banner, but it still forces users to navigate manually back to Step 3. Reuse the existing replay/restart route logic so Step 5 can surface a direct recovery card whenever the current simulation has replayable run state and the interview environment is no longer alive.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T03:02:39Z","created_by":"Codex","updated_at":"2026-03-12T03:05:54Z","closed_at":"2026-03-12T03:05:54Z","close_reason":"Completed: Step 5 now exposes a direct Step 3 replay/restart recovery CTA when the interview environment is offline, with focused recovery-state tests and passing frontend validation.","dependencies":[{"issue_id":"mirofish-31if","depends_on_id":"mirofish-qoo","type":"discovered-from","created_at":"2026-03-12T03:02:38Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-es40","title":"Track Step 5 recovery CTA mitigation for upstream issue #9","description":"Link the Step 5 offline-interview recovery improvement back to the upstream resume/recovery request so the user-facing mitigation is visible separately from the longer-term checkpoint/resume design.","status":"closed","priority":2,"issue_type":"task","owner":"codex@local","created_at":"2026-03-12T03:02:39Z","created_by":"Codex","updated_at":"2026-03-12T03:05:54Z","closed_at":"2026-03-12T03:05:54Z","close_reason":"Superseded by mirofish-31if plus refreshed upstream coverage/triage artifacts; no separate implementation remains.","dependencies":[{"issue_id":"mirofish-es40","depends_on_id":"mirofish-qoo","type":"discovered-from","created_at":"2026-03-12T03:02:39Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-i8ww","title":"Preflight Step 4 against backend capability status","description":"Narrow follow-up under mirofish-gd5z / upstream issue #156. Step3Simulation currently lets users click into Step 4 even when the backend config-status payload already says graph-backed report tools are unavailable because ZEP_API_KEY is missing. Add a lightweight frontend preflight/capability warning before calling /api/report/generate, reuse the existing direct-LLM-vs-Zep guidance, and cover the decision logic with focused tests.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T02:57:45Z","created_by":"Codex","updated_at":"2026-03-12T02:59:12Z","closed_at":"2026-03-12T02:59:12Z","close_reason":"Completed","dependencies":[{"issue_id":"mirofish-i8ww","depends_on_id":"mirofish-gd5z","type":"discovered-from","created_at":"2026-03-12T02:57:44Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-u5l6","title":"Remove fake fallback Step 4 report reference placeholder","description":"Follow-up to upstream issue #158 / beads parent mirofish-gytl. Step4Report currently renders a fake placeholder report reference (REF-2024-X92) when no real report_id exists, which can mislead users trying to preserve verification artifacts. Replace it with the existing localized unavailable copy, add focused frontend regression coverage, and reconcile the parent notes after landing.","status":"closed","priority":2,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T02:54:37Z","created_by":"Codex","updated_at":"2026-03-12T02:55:45Z","closed_at":"2026-03-12T02:55:45Z","close_reason":"Completed: Step 4 and Step 5 now replace the fake fallback report reference with localized unavailable copy, covered by frontend/tests/reportReferences.test.mjs and validated with npm --prefix frontend test plus npm --prefix frontend run build.","dependencies":[{"issue_id":"mirofish-u5l6","depends_on_id":"mirofish-gytl","type":"discovered-from","created_at":"2026-03-12T02:54:36Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-n2rx","title":"Surface Step 3 replay/restart entrypoint in Step 2","description":"Follow-up to upstream issue #9 / beads issue mirofish-qoo. The replay-only Step 3 route already exists, but recovering a failed or stopped simulation still requires opening history first. Add a Step 2 recovery banner/action that detects saved Step 3 state and deep-links back into the same simulation's replay/restart flow so users can retry after fixing quota/API-key problems without rebuilding Step 2.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T02:49:53Z","created_by":"Codex","updated_at":"2026-03-12T02:52:31Z","closed_at":"2026-03-12T02:52:31Z","close_reason":"Step 2 now surfaces a direct Step 3 replay/restart recovery card for saved runs, with frontend tests and build validation.","dependencies":[{"issue_id":"mirofish-n2rx","depends_on_id":"mirofish-qoo","type":"discovered-from","created_at":"2026-03-12T02:49:53Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-6sr2","title":"Improve Step 3 report-start diagnostics for direct-LLM/Zep split","description":"Follow-up from upstream issue #156 / parent mirofish-gd5z. When Step 3 launches Step 4 report generation and the backend returns the structured config-status 503 payload, the frontend currently logs a generic raw error string. Surface localized, actionable guidance that distinguishes the working direct LLM path from the still-Zep-gated graph/report path so users understand why report generation is blocked.","status":"closed","priority":2,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T02:47:06Z","created_by":"Codex","updated_at":"2026-03-12T02:47:53Z","closed_at":"2026-03-12T02:47:53Z","close_reason":"Completed: Step 3 report-start failures now use the shared API error formatter, append the direct-LLM-vs-Zep capability hint from the backend config-status payload when present, and are covered by frontend/tests/apiErrors.test.mjs plus npm --prefix frontend test and npm --prefix frontend run build.","dependencies":[{"issue_id":"mirofish-6sr2","depends_on_id":"mirofish-gd5z","type":"discovered-from","created_at":"2026-03-12T02:47:06Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-9a8q","title":"Upgrade optional simulation Pillow dependency","description":"The default backend install path no longer pulls vulnerable document-processing extras, but backend/uv.lock still resolves pillow==10.3.0 for the optional simulation stack via camel-ai / sentence-transformers. Re-solve the optional simulation dependency graph to a newer Pillow if feasible, validate backend-lite plus lock consistency, and update upstream triage/coverage notes.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T02:36:53Z","created_by":"Codex","updated_at":"2026-03-12T02:38:49Z","closed_at":"2026-03-12T02:38:49Z","close_reason":"Re-locked backend/uv.lock so the optional simulation stack now resolves pillow==10.4.0, refreshed upstream coverage/snapshot artifacts, and validated with uv sync --extra simulation --frozen --dry-run plus bash ./scripts/test_backend_lite.sh.","dependencies":[{"issue_id":"mirofish-9a8q","depends_on_id":"mirofish-6yh","type":"discovered-from","created_at":"2026-03-12T02:36:53Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-pc6o","title":"Embed verification references in exported reports","description":"Follow-up to mirofish-gytl / upstream issue #158. Make full_report.md self-identifying by embedding report_id, simulation_id, and related verification metadata directly in the assembled Markdown so exported forecast evidence remains auditable outside the UI/history modal.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T02:31:25Z","created_by":"Codex","updated_at":"2026-03-12T02:33:35Z","closed_at":"2026-03-12T02:33:35Z","close_reason":"Completed: exported Step 4 Markdown now carries self-contained verification references and regression coverage.","dependencies":[{"issue_id":"mirofish-pc6o","depends_on_id":"mirofish-gytl","type":"discovered-from","created_at":"2026-03-12T02:31:25Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-djp9","title":"Clarify same-simulation restart path in Step 3 replay mode","description":"Upstream issue #9 is still partially confusing in local UX: replay/history mode can restart the same prepared simulation after users fix quota/API-key problems, but Step 3 notices still imply they must return to Step 2 and start over. Update replay-mode copy/button cues to make the reuse path explicit and add tests.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T02:26:18Z","created_by":"Codex","updated_at":"2026-03-12T02:27:59Z","closed_at":"2026-03-12T02:27:59Z","close_reason":"Clarified replay/history UX so failed or stopped Step 3 runs explicitly advertise same-simulation restart after quota/API-key fixes, with regression tests.","dependencies":[{"issue_id":"mirofish-djp9","depends_on_id":"mirofish-qoo","type":"discovered-from","created_at":"2026-03-12T02:26:18Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-fvf2","title":"Clarify same-simulation restart path in Step 3 replay mode","description":"Upstream issue #9 is still partially confusing in local UX: replay/history mode can restart the same prepared simulation after users fix quota/API-key problems, but Step 3 notices still imply they must return to Step 2 and start over. Update replay-mode copy/button cues to make the reuse path explicit and add tests.","status":"closed","priority":2,"issue_type":"task","owner":"codex@local","created_at":"2026-03-12T02:26:18Z","created_by":"Codex","updated_at":"2026-03-12T02:26:25Z","closed_at":"2026-03-12T02:26:25Z","close_reason":"Duplicate of mirofish-djp9 created during parallel tracker mutation","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-stm1","title":"Use browser locale for first-run i18n default","description":"Repo-native follow-up to the partial upstream PR #119 English support work. frontend/src/i18n/index.js currently falls back to a hardcoded locale when no stored preference exists, which forces first-time visitors into the wrong language. Resolve the initial locale from navigator.language when available while preserving explicit stored choices and stable zh/en behavior, then add focused frontend regression coverage.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T02:22:37Z","created_by":"Codex","updated_at":"2026-03-12T02:24:08Z","closed_at":"2026-03-12T02:24:08Z","close_reason":"Resolved first-run locale from navigator.language while preserving stored preference, added focused frontend i18n coverage, and refreshed upstream PR #119 snapshot artifacts.","dependencies":[{"issue_id":"mirofish-stm1","depends_on_id":"mirofish-57e","type":"discovered-from","created_at":"2026-03-12T02:22:36Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-hx6b","title":"Document forecast verification workflow from saved history/report artifacts","description":"Execution-ready child for upstream issue #158 / beads parent mirofish-gytl. Add repo-native docs that explain how to use saved history entries, report exports, and stable simulation/report IDs to compare a prior forecast against later real-world outcomes without claiming that the repo ships a built-in ground-truth dataset. Update parent notes after landing.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T02:03:55Z","created_by":"Codex","updated_at":"2026-03-12T02:05:15Z","closed_at":"2026-03-12T02:05:15Z","close_reason":"Documented repo-native forecast verification workflow in README.md and README-EN.md; refreshed upstream coverage artifacts.","dependencies":[{"issue_id":"mirofish-hx6b","depends_on_id":"mirofish-gytl","type":"discovered-from","created_at":"2026-03-12T02:03:54Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-gytl","title":"Track forecast verification UX/docs follow-up from upstream issue #158","description":"Upstream issue #158 asks whether MiroFish predictions can be verified against later real-world events. This repo does not currently expose an explicit forecast verification workflow or examples in product/docs. Preserve the request locally so a later pass can determine whether the right answer is documentation, report metadata, or a new evaluation feature.","notes":"2026-03-12: Closed child issue mirofish-hct2 after adding translated README parity for the manual forecast-verification workflow in README-RU.md, README-KO.md, and README-JA.md. The README set now consistently explains how to preserve report_id/simulation_id evidence, export Markdown artifacts, and distinguish the current manual-review path from true automated backtesting. Automatic ground-truth ingestion / scoring is still not implemented, so the parent remains open.","status":"in_progress","priority":2,"issue_type":"feature","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T01:54:54Z","created_by":"Codex","updated_at":"2026-03-12T04:03:33Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-gvwc","title":"Expose capability matrix for direct OpenAI-compatible mode","description":"Follow-up under mirofish-gd5z. The config-status payload currently tells users whether direct OPENAI_* / Codex-compatible aliases were resolved, but it does not expose step-level capability boundaries. Add a machine-readable capability matrix that distinguishes direct LLM readiness from Zep-dependent Step 1 graph build / graph-backed report tooling and clarifies what remains usable in non-Zep mode, then surface it in diagnostics/docs with regression coverage.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T01:48:42Z","created_by":"Codex","updated_at":"2026-03-12T01:52:01Z","closed_at":"2026-03-12T01:52:01Z","close_reason":"Implemented capability matrix in config-status, frontend diagnostics, and docs with backend/frontend regression coverage","dependencies":[{"issue_id":"mirofish-gvwc","depends_on_id":"mirofish-gd5z","type":"discovered-from","created_at":"2026-03-12T01:48:42Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-gd5z","title":"Track non-Zep simulation-only workflow request from upstream issue #156","description":"Upstream issue #156 asks for a workflow that avoids drawing or depending on the Zep graph because free quota is exhausted, while still allowing simulation and role interaction. The current repo supports direct OPENAI_* / Codex-compatible LLM backends, but Step 1 graph build and most report/interaction flows still depend on Zep-backed graph data. Scope follow-up around either a repo-native simulation-only mode without Zep or clearer product/docs boundaries if full bypass is not feasible.","notes":"2026-03-12: Landed another repo-native mitigation via child issue mirofish-3is2. The frontend now supports a simulation-only Step 5 route through frontend/src/components/interactionRoute.js, frontend/src/router/index.js, and frontend/src/views/InteractionView.vue, so users on direct OPENAI_* / Codex-compatible backends can continue into Step 5 without a report_id when Step 4 is blocked on missing ZEP_API_KEY. Step3Simulation.vue now surfaces that shortcut immediately when report preflight blocks Step 4, Step4Report.vue offers the same escape hatch from failed-report states, and Step5Interaction.vue now degrades cleanly into interaction-only mode without pretending a Step 4 report exists. Validated with npm --prefix frontend test and npm --prefix frontend run build.\n2026-03-12: Landed another repo-native mitigation via child issue mirofish-a8io. HistoryDatabase.vue now exposes a Step 5 button in the saved-run modal that reuses the existing interaction route helper to open either the report-backed or simulation-only interaction workspace, so users blocked on Step 4/Zep can resume Step 5 directly from history instead of navigating back through Step 3/4. Validated with npm --prefix frontend test and npm --prefix frontend run build.\n2026-03-12: Revalidated the direct Codex/OpenAI-compatible backend path on current HEAD with env -i using only OPENAI_API_KEY, OPENAI_API_BASE_URL=https://codex.example.test/v1, OPENAI_MODEL=gpt-4.1-mini, and ZEP_API_KEY. npm run check:backend-config still reports llm.backend_mode=openai_compatible, direct_llm.ready=true, and only the expected generated SECRET_KEY warning when SECRET_KEY is unset. Full non-Zep graph/report replacement is still not implemented; this note only reconfirms the direct LLM + existing Step 5 interaction path remains healthy.\n2026-03-12: Landed another repo-native non-Zep mitigation via child issue mirofish-fze7. The frontend backend diagnostics panel now adds explicit next-step guidance when a direct OPENAI_* / Codex-compatible backend is configured but ZEP_API_KEY is missing, telling users to continue through Step 2/3 and then reuse Step 5 interaction on an existing simulation while Step 1 graph build and Step 4 graph-backed report tools remain blocked. Validated with npm --prefix frontend test and npm --prefix frontend run build.","status":"open","priority":2,"issue_type":"feature","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T01:41:05Z","created_by":"Codex","updated_at":"2026-03-12T03:40:11Z","dependencies":[{"issue_id":"mirofish-gd5z","depends_on_id":"mirofish-8eg","type":"discovered-from","created_at":"2026-03-12T01:41:05Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-c1nu","title":"Surface Step 3 replay/resume limitation in the panel UI","description":"Replay-only Step 3 currently logs when no prior run can be resumed, but the main panel does not surface that limitation visibly. Add a small visible notice for replay-only/no-run and replay-only/failed-session states so users understand that history replay does not recreate a dead runtime session or true checkpoint resume.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T01:36:10Z","created_by":"Codex","updated_at":"2026-03-12T01:37:28Z","closed_at":"2026-03-12T01:37:28Z","close_reason":"Step3Simulation now shows a visible replay-only notice when history replay has no saved run to reopen or only a failed state to inspect. Validated with npm --prefix frontend test and npm --prefix frontend run build.","dependencies":[{"issue_id":"mirofish-c1nu","depends_on_id":"mirofish-qoo","type":"discovered-from","created_at":"2026-03-12T01:36:09Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-fxps","title":"Refresh March 12 upstream intake and safe-PR queue","description":"Refresh machine-readable upstream issue/PR snapshots for 666ghj/MiroFish, re-evaluate the remaining open PR queue after the latest GitHub intake, and record whether any new safe cherry-picks emerged or whether the queue still consists only of already-landed, partial, tracked, or unsafe branches.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T01:33:44Z","created_by":"Codex","updated_at":"2026-03-12T01:34:10Z","closed_at":"2026-03-12T01:34:10Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-tctk","title":"Preserve fork issue mirror metadata when reusing stale upstream snapshots","description":"When scripts/sync_upstream_github.py falls back to a fresh cached snapshot after GitHub rate limiting, the rewritten markdown summary currently omits mirror_issues_repo and can drop the mirrored-issues visibility line even though the JSON snapshot still contains that metadata. Fix the cache-reuse path so issue-mirror visibility survives stale-cache regeneration, and add regression coverage.","status":"closed","priority":2,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T01:30:06Z","created_by":"Codex","updated_at":"2026-03-12T01:31:26Z","closed_at":"2026-03-12T01:31:26Z","close_reason":"Fixed stale-cache upstream summary mirror metadata preservation and added regression coverage","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-htyf","title":"Refresh upstream intake and verify direct OpenAI-compatible path (pass 26)","description":"Refresh machine-readable issue/PR snapshots for 666ghj/MiroFish, confirm fork mirror coverage, re-verify the direct OPENAI_/Codex-compatible backend configuration path, and record whether a new safe upstream PR or low-risk issue surfaced in this pass.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T01:12:20Z","created_by":"Codex","updated_at":"2026-03-12T01:12:51Z","closed_at":"2026-03-12T01:12:51Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-4hiw","title":"Restore fork-mirror flags on refresh_upstream_snapshots wrapper","description":"scripts/refresh_upstream_snapshots.sh still forwards --fork-remote and --mirror-issues-repo to sync_upstream_github.py internally, but its public CLI rejects those options. That breaks callers that need explicit fork mirror settings during upstream intake refresh and caused this evolve pass to fail on the first refresh attempt. Fix the wrapper interface and add regression coverage.","status":"closed","priority":2,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T01:08:26Z","created_by":"Codex","updated_at":"2026-03-12T01:09:53Z","closed_at":"2026-03-12T01:09:53Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-2ul1","title":"Track repo-native review of upstream PR #155 combined i18n/docker sweep","description":"Upstream PR #155 (english-trans) is now mirrored as origin/mirror/upstream-pr-155. The branch rewrites large backend/frontend/i18n/docker surfaces on an older tree, so it is not safe to cherry-pick wholesale. Track any future repo-native subset work separately from the automated mirror/triage refresh.","notes":"2026-03-12: Fixed another machine-readable intake contract gap while continuing PR #155 / upstream triage maintenance. scripts/sync_upstream_github.py now emits a non-null refreshed_at alias on both live refresh and cached-snapshot reuse paths, and docs/upstream-open-state.json plus docs/upstream-all-state.json were regenerated so downstream consumers can trust the snapshot timestamp field again even when GitHub rate limiting forces cache reuse.\n2026-03-12: Refreshed upstream intake again at 2026-03-12T03:32:51Z (open) / 2026-03-12T03:33:03Z (full). Machine-readable snapshots remain current at 46 open issues / 40 open PRs and 96 total issues / 54 total PRs, with all open upstream issues mirrored into ivanzud/MiroFish and all open PR heads still visible in origin. Safe-merge review is still unchanged: 22 landed, 7 superseded, 6 not_safe, 2 tracked, 2 partial, and 1 covered, so PR #155 remains tracked-only rather than a safe cherry-pick.\n2026-03-12: Refreshed upstream intake again at 2026-03-12T03:52:03Z (open) / 2026-03-12T03:52:12Z (full). Machine-readable snapshots remain current at 46 open issues / 40 open PRs and 96 total issues / 54 total PRs, all open upstream issues remain mirrored into ivanzud/MiroFish, and another safe-merge review still found no new clean PR beyond the existing tracked/partial/not-safe queue.","status":"open","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T00:50:41Z","created_by":"Codex","updated_at":"2026-03-12T03:52:15Z","dependencies":[{"issue_id":"mirofish-2ul1","depends_on_id":"mirofish-qd30","type":"discovered-from","created_at":"2026-03-12T00:50:40Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-dguk","title":"Refresh upstream intake and reverify direct OpenAI-compatible backend","description":"Pass for 2026-03-12: refresh open/all upstream GitHub snapshots for 666ghj/MiroFish, confirm issue/PR mirroring remains current in the fork, review the open PR queue for any newly safe merge candidates, and reverify the direct OPENAI_* / Codex-compatible backend path plus lightweight backend tests before recording the result in repo artifacts.","status":"closed","priority":2,"issue_type":"chore","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T00:45:05Z","created_by":"Codex","updated_at":"2026-03-12T00:45:50Z","closed_at":"2026-03-12T00:45:50Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-2w0z","title":"Refresh upstream intake and direct backend verification for 2026-03-12 pass","description":"Execution slice for the current evolve pass: refresh upstream open/full issue+PR snapshots, verify mirror visibility in origin/ivanzud fork artifacts, re-evaluate the safe open PR queue, and revalidate the direct OPENAI_* / Codex-compatible backend path with current tests so objective #7 stays current.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T00:38:31Z","created_by":"Codex","updated_at":"2026-03-12T00:38:50Z","closed_at":"2026-03-12T00:38:50Z","close_reason":"Refreshed upstream open/full snapshots to 42 open issues / 39 open PRs and 92 total issues / 53 total PRs, confirmed fork issue/PR mirror visibility remains current, revalidated the direct OPENAI_* / Codex-compatible backend path, and reran backend-lite validation with 166 passing tests.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-kmkq","title":"Backfill upstream coverage map for stale closed issue/PR gaps","description":"Fresh upstream sync on 2026-03-12 still shows several closed upstream issue/PR items as missing in docs/upstream-coverage.json even though the current branch already addresses them (for example issue #121 network-error diagnostics, issue #109 first-run quota guidance, issue #77 Zep auth guidance, and PR #127 None-content LLMClient guard). Add machine-readable coverage entries, regenerate upstream summaries, and keep fork visibility artifacts current.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T00:33:05Z","created_by":"Codex","updated_at":"2026-03-12T00:35:59Z","closed_at":"2026-03-12T00:35:59Z","close_reason":"Backfilled upstream coverage entries for issues #77/#109/#121 and PR #127, regenerated upstream snapshots, and reran focused validation for the touched frontend/backend surfaces.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-q0x6","title":"Refresh March 12 upstream intake and safe-merge review","description":"Forced another refresh of the open/full 666ghj/MiroFish issue+PR snapshots, verify mirror coverage remains current, and re-check the open PR queue for a newly safe cherry-pick before closing the pass.","status":"closed","priority":2,"issue_type":"chore","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T00:19:09Z","created_by":"Codex","updated_at":"2026-03-12T00:19:21Z","closed_at":"2026-03-12T00:19:21Z","close_reason":"Completed forced March 12 open/full upstream refresh, confirmed mirror coverage stayed current, confirmed no newly safe open upstream PR, and validated the refresh/sync pipeline with .tmp-test-venv pytest.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-aj3s","title":"Refresh upstream intake and revalidate direct backend path","description":"Scoped evolve pass on 2026-03-12. Refresh upstream issue/PR snapshots from 666ghj/MiroFish, confirm fork mirror visibility is still current, re-run the direct OpenAI-compatible backend config preflight using OPENAI_* aliases, and re-run the lightweight backend suite before reconciling triage docs.","status":"closed","priority":2,"issue_type":"chore","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T00:16:00Z","created_by":"Codex","updated_at":"2026-03-12T00:16:21Z","closed_at":"2026-03-12T00:16:21Z","close_reason":"Refreshed upstream issue/PR snapshots, confirmed all open PR refs and issue mirrors remain visible in the fork artifacts, revalidated OPENAI_* direct backend config preflight, and reran backend-lite successfully.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-xvq2","title":"Document backend:local preflight path in translated READMEs","description":"Scoped follow-up for the current evolve pass. README-JA.md, README-KO.md, and README-RU.md already document direct OpenAI-compatible / Codex-compatible backends via OPENAI_* aliases, but they still omit the newer npm run backend:local startup path that runs the same config preflight before starting Flask. Add that repo-native backend-only startup path and verification note consistently across the translated READMEs, validate with focused grep/review, and reconcile upstream/direct-backend notes after landing.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T23:58:58Z","created_by":"Codex","updated_at":"2026-03-11T23:59:37Z","closed_at":"2026-03-11T23:59:37Z","close_reason":"Added backend:local preflight documentation to README-JA.md, README-KO.md, and README-RU.md, aligning translated direct OpenAI-compatible setup docs with the shipped backend-only startup path.","dependencies":[{"issue_id":"mirofish-xvq2","depends_on_id":"mirofish-pfbl","type":"discovered-from","created_at":"2026-03-11T23:58:58Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-j3mz","title":"Broaden English interview parser variants","description":"Scoped follow-up to mirofish-pfbl. The frontend Step 4 interview parser still expected a narrow English markdown shape, so English-mode model output with variants like Topic, Agents Interviewed, Selection Rationale, Profile, Questions, Answer, Twitter Response, Reddit Response, or Question 1-style prompts could render as an unparsed transcript. Broaden the parser regexes for those deterministic variants, add focused regression coverage, and reconcile the parent localization bead after validation.","status":"closed","priority":2,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T23:52:10Z","created_by":"Codex","updated_at":"2026-03-11T23:52:16Z","closed_at":"2026-03-11T23:52:16Z","close_reason":"Frontend reportParsers now accept alternate English interview headings and Question 1-style prompts, covered by frontend/tests/reportParsers.test.mjs and validated with npm --prefix frontend test -- --runInBand reportParsers.test.mjs.","dependencies":[{"issue_id":"mirofish-j3mz","depends_on_id":"mirofish-pfbl","type":"discovered-from","created_at":"2026-03-11T23:52:10Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-o4q6","title":"Preserve flat PR mirror metadata in upstream sync snapshots","description":"Upstream sync JSON already records PR branch and mirror data, but only under the newer nested/renamed keys. Add backwards-compatible flat aliases like head_ref_name, base_ref_name, mirrored_to_origin, and mirror_ref so downstream automation can consume machine-readable upstream summaries without extra schema translation.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T23:44:37Z","created_by":"Codex","updated_at":"2026-03-11T23:44:44Z","closed_at":"2026-03-11T23:44:44Z","close_reason":"Added compatibility aliases for PR branch and mirror metadata in scripts/sync_upstream_github.py, covered them in tests/test_sync_upstream_github.py, and regenerated the upstream snapshot JSON artifacts.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-bw8s","title":"Sync translated README quick-start with setup:core and optional simulation runtime","description":"Japanese and Korean README quick-start sections still lag the current repo-native install flow. Update them to use npm run setup:core, document setup:all as a backward-compatible alias, and call out setup:backend:simulation as optional so translated docs stay aligned with the direct OpenAI-compatible core path.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T23:40:25Z","created_by":"Codex","updated_at":"2026-03-11T23:40:49Z","closed_at":"2026-03-11T23:40:49Z","close_reason":"Updated README-JA.md and README-KO.md to recommend setup:core, retain setup:all as the backward-compatible alias, and keep setup:backend:simulation explicitly optional.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-egat","title":"Make backend unittest module discovery work","description":"A reproducible validation bug remains in the current tree: running module-targeted unittest commands from backend/, such as python3 -m unittest tests.test_print_config_status, reports 0 tests because backend/tests is not importable as a package. Add the minimal packaging fix, validate the command, and keep the change isolated from runtime code.","status":"closed","priority":2,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T23:36:01Z","created_by":"Codex","updated_at":"2026-03-11T23:36:52Z","closed_at":"2026-03-11T23:36:52Z","close_reason":"Closed without code change after validation: backend/tests are pytest-style modules, so python -m unittest reports 0 tests by design. The real lightweight validation path remains bash ./scripts/test_backend_lite.sh or pytest-targeted commands.","dependencies":[{"issue_id":"mirofish-egat","depends_on_id":"mirofish-nmu","type":"discovered-from","created_at":"2026-03-11T23:36:01Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-53t3","title":"Clarify core setup path for direct OpenAI-compatible usage","description":"Users hitting upstream issue #153 and direct OpenAI/Codex-compatible setups still benefit from a sharper installation path that makes core graph/report usage distinct from optional Step 3/5 simulation runtime setup. Add an explicit core setup alias and update docs so OpenAI-compatible backend users can get running without accidentally assuming simulation extras are part of the default path.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T23:11:40Z","created_by":"Codex","updated_at":"2026-03-11T23:12:25Z","closed_at":"2026-03-11T23:12:25Z","close_reason":"Added explicit setup:core alias, kept setup:all as backward-compatible core-install alias, updated README variants to distinguish core OpenAI-compatible setup from optional simulation runtime install, and re-verified the direct OPENAI_* backend path.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-fren","title":"Refresh upstream intake and safe-PR review after upstream issue #153","description":"Refresh the machine-readable upstream open/full snapshots, verify all open issue/PR mirrors remain current in the fork, review the remaining open PR queue for any newly safe cherry-pick, and record the outcome for the next evolve cycle. This pass is complete when docs/upstream-*.json|md reflect the latest upstream state and the no-new-safe-PR result is documented.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T22:26:35Z","created_by":"Codex","updated_at":"2026-03-11T22:27:06Z","closed_at":"2026-03-11T22:27:06Z","close_reason":"Refreshed upstream open/full snapshots, confirmed mirror coverage remains complete, and recorded that the open PR queue still has no newly safe cherry-pick.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-o2l8","title":"Handle empty Report Agent chat responses from OpenAI-compatible backends","description":"ReportAgent.chat() currently assumes llm.chat() always returns a string. The report-generation path already guards None responses, but the interactive chat path can still pass None into tool parsing/regex cleanup or make a second call that returns None without a localized fallback. Harden the chat loop and final-response path with deterministic localized fallback text plus focused regression coverage.","status":"closed","priority":2,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T21:49:52Z","created_by":"Codex","updated_at":"2026-03-11T21:51:13Z","closed_at":"2026-03-11T21:51:13Z","close_reason":"ReportAgent.chat now handles empty backend responses with localized fallback text and focused regression coverage in backend/tests/test_report_agent.py; validated with targeted pytest, compileall, and scripts/test_backend_lite.sh.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-dujt","title":"Refresh upstream snapshots and verify direct OpenAI-compatible config status","description":"Refresh docs/upstream-open-state.json and docs/upstream-all-state.json against 666ghj/MiroFish, confirm mirror counts stay current, and re-verify the direct OPENAI_* alias path via npm run check:backend-config so the local triage record stays accurate.","status":"closed","priority":2,"issue_type":"chore","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T21:28:48Z","created_by":"Codex","updated_at":"2026-03-11T21:29:05Z","closed_at":"2026-03-11T21:29:05Z","close_reason":"Refreshed upstream snapshots, re-verified direct OPENAI_* config status, and updated the upstream triage ledger for the latest pass.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-b56g","title":"Preserve locale in report API early exception paths","description":"backend/app/api/report.py initializes locale inside several try blocks and then references it in except handlers. If request parsing or another early operation raises before locale assignment, the handler can trip an UnboundLocalError and lose the intended localized error context. Move locale resolution ahead of the try blocks for the affected routes and add regression coverage using a forced get_json failure in English mode.","status":"closed","priority":2,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T21:25:03Z","created_by":"Codex","updated_at":"2026-03-11T21:25:49Z","closed_at":"2026-03-11T21:25:49Z","close_reason":"Resolved report API locale preservation for early request-parse failures with regression coverage in backend/tests/test_report_api_i18n.py.","dependencies":[{"issue_id":"mirofish-b56g","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T21:25:02Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-xk5u","title":"Refresh upstream snapshots and localized setup docs","description":"Continue evolve pass by refreshing docs/upstream-* snapshots from 666ghj/MiroFish, verifying mirror visibility remains current, and tightening README localization parity around OpenAI-compatible backend setup and optional simulation runtime installation.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T21:00:02Z","created_by":"Codex","updated_at":"2026-03-11T21:01:15Z","closed_at":"2026-03-11T21:01:15Z","close_reason":"Refreshed upstream open/all snapshots, confirmed mirror counts remain current, and aligned README-KO/README-JA setup docs with the core-vs-simulation install split and browser-refresh runtime caveats.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-sn9t","title":"Expose stable machine-readable summaries in upstream sync snapshots","description":"Objective 2 follow-up. docs/upstream-open-state.json and docs/upstream-all-state.json include local coverage fields but do not emit a stable per-item summary/status string, which makes downstream machine-readable consumers depend on nested coverage structure or markdown parsing. Update scripts/sync_upstream_github.py to emit stable summary fields for issues and PRs, add regression coverage, refresh the upstream snapshot artifacts, and reconcile tracking once landed.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T20:46:49Z","created_by":"Codex","updated_at":"2026-03-11T20:47:48Z","closed_at":"2026-03-11T20:47:48Z","close_reason":"Sync snapshots now emit stable top-level triage summary/status fields, with regression coverage and refreshed upstream state artifacts.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-a9tu","title":"Warn on conflicting OpenAI-compatible base URL aliases","description":"Direct OpenAI/Codex-compatible support already accepts LLM_BASE_URL, OPENAI_BASE_URL, and OPENAI_API_BASE_URL, but conflicting values are silently resolved today. Add repo-native config validation and diagnostics so mismatched aliases become explicit, record the active value/source, and cover it with focused backend tests plus README guidance.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T20:41:57Z","created_by":"Codex","updated_at":"2026-03-11T20:44:09Z","closed_at":"2026-03-11T20:44:09Z","close_reason":"Landed backend/frontend diagnostics for conflicting OpenAI-compatible base URL aliases, refreshed upstream snapshots, updated triage/docs, and validated with focused backend/frontend tests.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-r6er","title":"Verify backend startup with OPENAI_* aliases only","description":"Objective 7 follow-up. The repo already documents and implements direct OpenAI/Codex-compatible backend wiring via OPENAI_* aliases, but backend/run.py lacks an explicit regression test proving startup validation passes when only OPENAI_API_KEY + OPENAI_BASE_URL/OPENAI_API_BASE_URL + OPENAI_MODEL are configured. Add focused startup-path verification and reconcile docs/tests if needed.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T19:40:10Z","created_by":"Codex","updated_at":"2026-03-11T19:40:45Z","closed_at":"2026-03-11T19:40:45Z","close_reason":"Added backend/run.py regression coverage for OPENAI_* alias-only startup validation, aligned startup validation test wording with current alias-aware messages, and passed focused backend tests.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-s3jw","title":"Verify and align Codex/OpenAI-compatible backend docs","description":"Objective 7 follow-up. The direct OpenAI-compatible backend path is already implemented and exposed via scripts/print_config_status.py plus npm run check:backend-config, but README-RU is missing the fuller alias example and explicit OPENAI_API_BASE_URL verification flow present in the other READMEs. Verify the path end-to-end with OPENAI_* env vars, align the Russian README with the current documented behavior, and keep the docs consistent with the existing config-status diagnostics.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T18:32:56Z","created_by":"Codex","updated_at":"2026-03-11T18:33:24Z","closed_at":"2026-03-11T18:33:24Z","close_reason":"Verified the direct OpenAI/Codex-compatible backend path with OPENAI_* env vars via npm run check:backend-config and aligned README-RU with the current alias and verification flow.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-d73x","title":"Add OpenAI-compatible config-status smoke coverage and README parity","description":"Follow up on upstream issue #32 and the repo's direct OpenAI-compatible backend support. Add an end-to-end backend script smoke test that verifies print_config_status.py reports openai_compatible when only OPENAI_* aliases are set, and update the non-English READMEs so they mention npm run check:backend-config alongside the existing /health and /api/graph/config/status verification flow.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T17:55:40Z","created_by":"Codex","updated_at":"2026-03-11T17:56:50Z","closed_at":"2026-03-11T17:56:50Z","close_reason":"Added an end-to-end print_config_status.py smoke test for OPENAI_* aliases and updated JA/KO/RU README verification guidance for the direct OpenAI-compatible path.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-m1k","title":"Add sequential upstream intake refresh helper","description":"The repo already relies on scripts/sync_upstream_github.py for open/all GitHub intake, but running both refreshes in parallel collides on the repo lock. Add a repo-native helper/script entry that refreshes open and all snapshots sequentially with the existing fork/coverage settings, document the workflow briefly, and close the task after validation.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T17:46:52Z","created_by":"Codex","updated_at":"2026-03-11T17:47:55Z","closed_at":"2026-03-11T17:47:55Z","close_reason":"Added scripts/refresh_upstream_snapshots.sh plus npm run sync:upstream, documented the workflow in docs/upstream-triage.md, and validated it with a live refresh plus focused backend config tests.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-y76","title":"Add CLI backend config-status diagnostic","description":"Objective 7 follow-up. Expose the same non-sensitive backend config summary/validation currently available via /api/graph/config/status through a repo-native CLI command so OpenAI/Codex-compatible wiring can be verified headlessly without starting the server or opening the frontend. Include tests and package script wiring.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T17:26:52Z","created_by":"Codex","updated_at":"2026-03-11T17:28:18Z","closed_at":"2026-03-11T17:28:18Z","close_reason":"Added a headless backend config-status CLI, wired npm script/docs, and covered it in the lightweight backend test bundle.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-akv","title":"Refresh upstream snapshots and reconcile green validation state","description":"Evolve pass on 2026-03-11: refresh open/all upstream issue+PR snapshots for 666ghj/MiroFish, verify mirror coverage remains complete, and update stale triage notes that still claim backend pytest is failing even though the current full backend/frontend gates are green.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T17:09:09Z","created_by":"Codex","updated_at":"2026-03-11T17:09:33Z","closed_at":"2026-03-11T17:09:33Z","close_reason":"Refreshed open/all upstream summaries, confirmed mirror coverage remains complete, revalidated the branch with full backend/frontend gates, and reconciled stale triage notes to the current green state.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-nja","title":"Investigate upstream issue #149 waiting-for-agent-actions stall","description":"Upstream issue #149 (mirrored as fork issue #90) reports Step 3 stuck at 'Waiting for agent actions' but currently provides only a screenshot and no logs, provider details, or simulation status payloads. Next pass should reproduce against the current branch, compare with existing fixes around incremental polling, env liveness, and long-running simulation guidance, then either map it to an already-covered failure mode or land a targeted fix with regression coverage.","status":"closed","priority":2,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T16:58:04Z","created_by":"Codex","updated_at":"2026-03-11T17:03:17Z","closed_at":"2026-03-11T17:03:17Z","close_reason":"Implemented stale Step 3 run-state reconciliation and waiting diagnostics","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-p84","title":"Expose top-level local status in upstream sync snapshots","description":"The upstream issue/PR snapshots are machine-readable, but evolve-style queries currently have to inspect nested local_coverage.status fields. Add explicit top-level local_status/local_summary fields for compacted issues and PRs, preserve nested detail, and cover the behavior with sync script tests so unresolved-item selection is simpler and less error-prone.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T16:56:50Z","created_by":"Codex","updated_at":"2026-03-11T16:58:58Z","closed_at":"2026-03-11T16:58:58Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-4vy","title":"Stabilize report API i18n test patch points","description":"Follow-up from evolve validation. Full backend pytest currently fails in tests/test_report_api_i18n.py because app.api.report no longer exposes stable monkeypatch points for ReportManager lookup and logger interception after logger/report_manager imports were refactored. Restore explicit patchable seams with the smallest repo-native fix, add/update regression coverage, and reconcile the parent localization issue notes if this closes the last low-risk gap.","status":"closed","priority":2,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T16:47:19Z","created_by":"Codex","updated_at":"2026-03-11T16:48:33Z","closed_at":"2026-03-11T16:48:33Z","close_reason":"Updated backend/tests/test_report_api_i18n.py to patch live route-module objects directly instead of brittle dotted class paths; targeted and full backend pytest now pass.","dependencies":[{"issue_id":"mirofish-4vy","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T16:47:18Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-pzk","title":"Allow upstream sync refreshes to wait on repo lock","description":"sync_upstream_github.py currently hard-fails when an open-state refresh and a full-state refresh overlap. Add a bounded wait-for-lock option so autonomous/evolve passes can self-serialize open/all snapshot refreshes instead of requiring a manual rerun after RuntimeError: already refreshing.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T16:24:25Z","created_by":"Codex","updated_at":"2026-03-11T16:25:29Z","closed_at":"2026-03-11T16:25:29Z","close_reason":"Added --lock-wait-seconds to sync_upstream_github.py, covered bounded wait/fail-fast lock behavior with unittest cases, and refreshed open/all upstream snapshots sequentially.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-1kg","title":"Surface OpenAI-compatible backend diagnostics in API endpoint control","description":"Objective 7 is already supported in backend/docs/tests via /api/graph/config/status, but the frontend API endpoint panel only shows the client-side base URL override. Add a small repo-native diagnostics surface that fetches the non-sensitive backend config summary, shows whether the backend resolved project-specific LLM_* vars or direct OPENAI_* aliases, and labels openai_compatible mode clearly for Codex/OpenAI-compatible gateways. Keep it incremental with focused frontend helper tests and update upstream triage notes after landing.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T15:54:57Z","created_by":"Codex","updated_at":"2026-03-11T15:56:49Z","closed_at":"2026-03-11T15:56:49Z","close_reason":"Added frontend diagnostics for backend config-status so the API endpoint panel now surfaces OpenAI-compatible mode, env-source family, effective base URL/model, plus helper tests and refreshed upstream triage notes.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-857","title":"Refresh upstream snapshots and reconcile issue #146 intake","description":"Run the open/all upstream GitHub sync sequentially, confirm fork mirror visibility remains current, and update triage notes for the new upstream issue #146 plus the latest 2026-03-11 counts so the next evolve pass resumes from current machine-readable state.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T15:50:59Z","created_by":"Codex","updated_at":"2026-03-11T15:51:39Z","closed_at":"2026-03-11T15:51:39Z","close_reason":"Refreshed open/all upstream snapshots, exported beads state, and updated triage notes for new upstream issue #146 plus latest 2026-03-11 counts.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-7hh","title":"Refresh upstream intake and execute next safe repo-native fix","description":"Continue the evolve loop for 666ghj/MiroFish: refresh open/full upstream issue and PR summaries, mirror upstream issues/PR refs into the fork where practical, review open PRs for safe landing/cherry-pick opportunities, then implement the next high-signal low-risk fix or documentation improvement that remains actionable on the current branch.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T15:36:37Z","created_by":"Codex","updated_at":"2026-03-11T15:40:04Z","closed_at":"2026-03-11T15:40:04Z","close_reason":"Refreshed open/full upstream summaries with fork mirroring, then landed the next safe repo-native fix by covering upstream issue #146 with an opt-in hook workflow.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-8jz","title":"Mirror upstream GitHub issues into fork summaries","description":"The upstream intake script already supports --mirror-issues-repo, but the current open/full snapshots were refreshed without fork issue mirroring metadata. Create or update fork issue mirrors for actionable upstream issues in ivanzud/MiroFish, then refresh docs/upstream-open-state.json and docs/upstream-all-state.json so summaries include fork_issue_* metadata and mirrored counts.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T15:32:58Z","created_by":"Codex","updated_at":"2026-03-11T15:34:45Z","closed_at":"2026-03-11T15:34:45Z","close_reason":"Mirrored all current upstream issues into ivanzud/MiroFish via sync_upstream_github.py and refreshed the open/full machine-readable summaries so they now carry fork_issue_* metadata and mirrored issue counts.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-5qj","title":"Localize remaining Process graph-build fallback strings","description":"Follow-up to the partial local coverage for upstream PR #119 / issue #117. Process.vue still hardcodes several user-facing graph-build progress and fallback labels (for example default progress text, build-complete loading text, unnamed node labels, unknown edge endpoint names) outside the locale tables. Move them behind the existing process i18n keys with lightweight regression coverage.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T15:29:56Z","created_by":"Codex","updated_at":"2026-03-11T15:30:55Z","closed_at":"2026-03-11T15:30:55Z","close_reason":"Localized remaining Process graph-build fallback/progress strings, extracted graph mapping helper, and added frontend regression coverage.","dependencies":[{"issue_id":"mirofish-5qj","depends_on_id":"mirofish-ohy","type":"discovered-from","created_at":"2026-03-11T15:29:55Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-1zb","title":"Reconcile fork issue mirrors for upstream closed-state changes","description":"The new sync flow mirrors open upstream issues into ivanzud/MiroFish and updates them idempotently, but it does not yet close or retitle stale fork issues when an upstream issue later closes or falls out of the open sync set. Add closed-state reconciliation using the full-history snapshot so fork issue visibility stays accurate without manual cleanup.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T15:05:32Z","created_by":"Codex","updated_at":"2026-03-11T15:12:11Z","closed_at":"2026-03-11T15:12:11Z","close_reason":"Completed","dependencies":[{"issue_id":"mirofish-1zb","depends_on_id":"mirofish-57t","type":"discovered-from","created_at":"2026-03-11T15:05:31Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-7xp","title":"Restore positional repo argument compatibility for upstream sync script","description":"scripts/sync_upstream_github.py now requires --repo, but prior wrappers and operator muscle memory may still pass the repository as a first positional argument. Accept the legacy positional repo form as a backward-compatible alias, add regression coverage, and refresh tracker export.","status":"closed","priority":2,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T14:10:18Z","created_by":"Codex","updated_at":"2026-03-11T14:11:44Z","closed_at":"2026-03-11T14:11:44Z","close_reason":"Restored legacy positional repo argument support in scripts/sync_upstream_github.py, added regression coverage, and verified both open/all live sync runs with mirrored fork annotation.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-bbn","title":"Mirror remaining upstream PR refs into fork","description":"The open PR queue is already mirrored, but docs/upstream-all-state.json still shows 13 closed upstream PR refs missing from origin mirror branches (#1, #2, #6, #10, #12, #13, #25, #33, #44, #89, #91, #97, #120). Mirror those refs into origin for full fork visibility, refresh upstream snapshots, and reconcile the triage notes/counts.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T14:00:59Z","created_by":"Codex","updated_at":"2026-03-11T14:03:15Z","closed_at":"2026-03-11T14:03:15Z","close_reason":"Mirrored the remaining upstream PR refs (#1, #2, #6, #10, #12, #13, #25, #33, #44, #89, #91, #97, #120) into origin/mirror, refreshed open/full upstream snapshots, normalized new issue #142 as no_action, and updated triage notes to reflect full fork PR visibility.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-y89","title":"Land route-level language selector across remaining workflow views","description":"Safe repo-native follow-up to upstream PR #119. The dedicated Step 2, Step 3, and Step 5 route views still omit the header language selector even though i18n state is already persisted globally. Add the existing LanguageSelector component to those headers, keep the layout intact, and validate the frontend build/tests.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T13:49:42Z","created_by":"Codex","updated_at":"2026-03-11T13:50:28Z","closed_at":"2026-03-11T13:50:28Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-975","title":"Track repo-native entity deduplication for upstream PR #141 / issue #145","description":"Upstream PR #141 is mirrored as origin/mirror/upstream-pr-141, and new upstream issue #145 now provides a concrete duplicate-entity reproduction (for example \"特朗普\" vs \"美国总统特朗普\"). The branch is cleanly mergeable upstream but not safe to cherry-pick here because it rewinds large portions of the current tree (tooling/tests/i18n/OpenAI-compat/docs work) while adding a large graph deduplication feature. Track a later repo-native evaluation/reimplementation with targeted graph-builder/Zep regression coverage instead of blind merge.","notes":"2026-03-11: Added a fifteenth repo-native partial mitigation in the shared frontend graph renderer. frontend/src/components/GraphPanel.vue now reuses the conservative display-only alias-collapse mapper via frontend/src/components/graphPanelData.js, so obvious duplicate aliases no longer render as separate nodes outside the Process view. Covered in frontend/tests/graphPanelData.test.mjs and validated with npm --prefix frontend test plus npm --prefix frontend run build.\n2026-03-11: Added a sixteenth repo-native partial mitigation in the Process and shared GraphPanel node detail drawers. frontend/src/components/graphAliasDetails.js now filters merged alias_names down to the non-canonical folded labels, and both frontend/src/views/Process.vue and frontend/src/components/GraphPanel.vue render those aliases explicitly so users can see which source labels were collapsed into the displayed canonical node. Covered by frontend/tests/graphAliasDetails.test.mjs and validated with npm --prefix frontend test plus npm --prefix frontend run build.\n2026-03-11: Added a seventeenth repo-native partial mitigation in frontend graph stats. Step1GraphBuild.vue, MainView.vue, and Process.vue now route graph node/edge counters through the shared frontend/src/components/graphPanelData.js normalization helper so visible counts and refresh logs match the alias-collapsed graph render instead of the raw duplicate-containing payload. Covered by frontend/tests/graphPanelData.test.mjs and validated with npm --prefix frontend test plus npm --prefix frontend run build.\n2026-03-12: Rechecked the repo-native alias-dedup surface after the latest upstream refresh and another code audit. No further low-risk display/read-only seams surfaced in this pass; the remaining gap is broader persisted graph deduplication / post-build mutation work, so this parent is moving back to open until a new execution-ready child is identified.\n2026-03-12: Landed a new repo-native frontend follow-up while auditing duplicate-entity display seams. frontend/src/views/Process.vue now compares a normalized graph signature instead of only raw node count during live polling, so edge-only graph mutations, alias-collapse remaps, and same-count node replacements refresh the Process graph correctly. Covered by frontend/tests/processGraphData.test.mjs and validated with npm --prefix frontend test plus npm --prefix frontend run build.","status":"open","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T13:40:07Z","created_by":"Codex","updated_at":"2026-03-12T00:42:24Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-yph","title":"Expose Step 5 interview timeout budget in UI","description":"Follow-up from refreshed upstream issue #62 timeout complaints. Step 5 already derives adaptive interview timeout seconds from the frontend request timeout and batch size, but the UI does not show that budget anywhere. Surface a concise timeout hint in Step5Interaction for single-agent chat and selected survey batches, add lightweight frontend tests, and refresh tracker/export notes after landing.","status":"closed","priority":2,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T13:27:57Z","created_by":"Codex","updated_at":"2026-03-11T13:29:27Z","closed_at":"2026-03-11T13:29:27Z","close_reason":"Surfaced Step 5 interview timeout budgets in the UI, added frontend helper coverage, refreshed upstream coverage mapping for issue #62, and validated with frontend test/build.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-e1a","title":"Add safe Step 3 replay entry from history","description":"History view should expose the existing Step 3 timeline/reattach path for simulations that already have run state, while preventing accidental fresh auto-starts when opened from history replay mode. This is a low-risk follow-up to upstream issue #9 interruption/reload behavior.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T13:23:38Z","created_by":"Codex","updated_at":"2026-03-11T13:25:32Z","closed_at":"2026-03-11T13:25:32Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-6hd","title":"Surface first-run upload size guidance for upstream issue #19","description":"Upstream issue #19 asks for clearer guidance that first-time users should avoid very large PDFs/documents. Add concise UI and README guidance near the upload/graph-build flow so users understand the low-risk onboarding path without changing backend behavior.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T13:18:59Z","created_by":"Codex","updated_at":"2026-03-11T13:20:31Z","closed_at":"2026-03-11T13:20:31Z","close_reason":"Added first-run upload guidance in UI/docs and refreshed upstream coverage snapshots.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-8rq","title":"Harden LLM JSON extraction for wrapped array payloads","description":"Mixed model outputs already recover wrapped JSON objects, but LLMClient._extract_json_payload() only falls back to {...} slicing when the payload is embedded in reasoning text. Add safe array extraction for wrapped [...] payloads, keep existing object behavior, and cover it with targeted backend tests.","status":"closed","priority":2,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T13:16:23Z","created_by":"Codex","updated_at":"2026-03-11T13:17:04Z","closed_at":"2026-03-11T13:17:04Z","close_reason":"Hardened LLMClient JSON payload extraction to recover wrapped top-level arrays from mixed model output, added backend/tests/test_llm_client.py coverage, and validated with uv run --project backend pytest -q backend/tests/test_llm_client.py backend/tests/test_openai_compat_services.py plus bash ./scripts/test_backend_lite.sh.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-98h","title":"Localize simulation start/stop validation and fix missing locale binding","description":"Follow-up from mirofish-1nh. backend/app/api/simulation.py uses locale before assignment in POST /api/simulation/start validation, which can turn missing simulation_id into a 500 instead of a localized 400. Also localize the hardcoded stop endpoint simulation_id validation error, add targeted regression coverage, and reconcile the parent notes after landing.","status":"closed","priority":2,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T13:13:38Z","created_by":"Codex","updated_at":"2026-03-11T13:14:13Z","closed_at":"2026-03-11T13:14:13Z","close_reason":"Bound locale before simulation start validation, localized stop validation, and added regression coverage.","dependencies":[{"issue_id":"mirofish-98h","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T13:13:37Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-wv5","title":"Make Docker image source overrideable for registry pull failures","description":"Follow-up from upstream issue #107. docker-compose.yml still hardcodes ghcr.io/666ghj/mirofish:latest with only a commented registry mirror hint, which makes EOF/blocked-registry pull failures harder to work around. Parameterize the image reference via env (keeping current default), document practical mirror/private-registry overrides in the README family, validate the compose syntax, and refresh upstream coverage tracking once landed.","status":"closed","priority":2,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T12:51:49Z","created_by":"Codex","updated_at":"2026-03-11T12:53:39Z","closed_at":"2026-03-11T12:53:39Z","close_reason":"Made Docker image source overrideable via MIROFISH_IMAGE, documented mirror/private-registry overrides in the README family and env template, validated docker compose config, and refreshed upstream coverage snapshots for issue #107.","dependencies":[{"issue_id":"mirofish-wv5","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T12:51:49Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-0f8","title":"Tighten default backend CORS origin policy","description":"Finish the safe residual from upstream PR #105 by replacing the wildcard default CORS origin list with a local-development allowlist, while preserving explicit CORS_* env overrides and documenting the new behavior in .env.example. Add backend regression tests for the default list and override behavior.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T12:39:16Z","created_by":"Codex","updated_at":"2026-03-11T12:40:19Z","closed_at":"2026-03-11T12:40:19Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-drs","title":"Improve simulation network failure diagnostics for HuggingFace download errors","description":"Follow-up from mirofish-1nh and upstream issue #68. Step 3 currently surfaces raw process-exit log tails when the simulation runtime dies during HuggingFace model/resource downloads, which makes network/proxy failures hard to diagnose. Classify the common deterministic HuggingFace/proxy failure path into a concise user-facing error, add backend regression coverage, and refresh upstream coverage artifacts once landed.","status":"closed","priority":2,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T12:22:59Z","created_by":"Codex","updated_at":"2026-03-11T12:23:59Z","closed_at":"2026-03-11T12:23:59Z","close_reason":"Classified HuggingFace download/proxy failures into concise Step 3 diagnostics, added simulation-runner regression tests, and refreshed upstream coverage snapshots for issue #68.","dependencies":[{"issue_id":"mirofish-drs","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T12:22:59Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-1y7","title":"Investigate upstream issue #24 report-generation NoneType crash","description":"Upstream issue #24 ('ERROR: 报告生成失败: 'NoneType' object is not subscriptable') is still open and not yet mapped in docs/upstream-coverage.json. Reproduce the report-generation crash path on the current branch, determine whether it is already indirectly fixed or still reachable, and either land a low-risk guard/regression test or record an exact non-actionable blocker with local references.","status":"closed","priority":2,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T12:14:44Z","created_by":"Codex","updated_at":"2026-03-11T12:17:21Z","closed_at":"2026-03-11T12:17:21Z","close_reason":"Covered locally with report-agent empty-response regression test","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-4b6","title":"Backfill machine-readable upstream coverage map for reviewed PRs/issues","description":"docs/upstream-triage.md contains concrete land/reject/superseded decisions for many upstream pull requests plus stale/open issue coverage, but docs/upstream-coverage.json only records five covered issues and zero pull requests. Normalize the local machine-readable coverage so future evolve passes can select truly uncovered upstream work instead of re-reviewing already-decided items.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T12:09:19Z","created_by":"Codex","updated_at":"2026-03-11T12:14:32Z","closed_at":"2026-03-11T12:14:32Z","close_reason":"Backfilled docs/upstream-coverage.json with reviewed open PR outcomes plus additional covered upstream issues, taught sync_upstream_github.py to surface PR coverage and overlay updated coverage during cached rate-limit reuse, regenerated open/full upstream snapshots, and validated with sync-script unit tests.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-ohy","title":"Finish English support sweep for remaining hardcoded UI strings","description":"Follow-up to upstream issue #117. The repo already has locale infrastructure, bilingual READMEs, and partial UI localization, but the remaining workflow/report/interaction surfaces still need a hardcoded-string sweep and regression tests so English users do not hit Chinese-only status text.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T12:03:17Z","created_by":"Codex","updated_at":"2026-03-11T12:07:45Z","closed_at":"2026-03-11T12:07:45Z","close_reason":"Completed","dependencies":[{"issue_id":"mirofish-ohy","depends_on_id":"mirofish-qoo","type":"discovered-from","created_at":"2026-03-11T12:03:16Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-a5b","title":"Localize report-agent fallback progress messages and refresh upstream coverage","description":"Follow-up pass: make ReportAgent emit English deterministic progress/tool/fallback strings when locale=en, add focused regression tests, and refresh docs/upstream-coverage.json so upstream issues #110 and #135 are annotated as locally covered in refreshed snapshots.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T12:01:01Z","created_by":"Codex","updated_at":"2026-03-11T12:01:46Z","closed_at":"2026-03-11T12:01:46Z","close_reason":"Localized ReportAgent deterministic English progress/fallback/tool strings, added focused regression coverage, and refreshed upstream issue coverage annotations for #110 and #135.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-309","title":"Track upstream issues #133/#139 coverage and fork visibility","description":"Upstream intake refreshed on 2026-03-11. Issue #133 (backend root 404 confusion) and issue #139 (Zep 401 graph-build traceback) appear already covered locally by the current branch, but there is no fork-visible machine-readable note tying the upstream reports to the landed local fixes. Create/update fork-visible tracking or docs so the next evolve pass can decide whether to mirror these as fork issues, upstream comments, or triage notes without re-investigating coverage.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T11:54:52Z","created_by":"Codex","updated_at":"2026-03-11T11:56:44Z","closed_at":"2026-03-11T11:56:44Z","close_reason":"Added docs/upstream-coverage.json plus sync-script support so refreshed upstream summaries annotate issues #133 and #139 as covered locally; direct fork issue mirroring is blocked because ivanzud/MiroFish disables issues.","dependencies":[{"issue_id":"mirofish-309","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T11:54:51Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-39p","title":"Tighten OPENAI alias propagation for standalone runners","description":"Direct OpenAI/Codex-compatible backend support is documented and tested at config level, but backend/scripts/llm_env.py currently only sets OPENAI_API_KEY and base URL aliases when values are non-empty. In-process reuse can leave stale OPENAI_* values behind, and OPENAI_MODEL is never synchronized for tools that expect the full standard alias set. Update the helper to fully synchronize/clear aliases and cover it with tests.","status":"closed","priority":2,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T11:51:42Z","created_by":"Codex","updated_at":"2026-03-11T11:52:36Z","closed_at":"2026-03-11T11:52:36Z","close_reason":"Completed: runner OpenAI alias helper now syncs/clears OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_API_BASE_URL, and OPENAI_MODEL; updated runner call sites and added regression tests; validated with ./.tmp-test-venv/bin/pytest backend/tests/test_llm_env.py -q and bash ./scripts/test_backend_lite.sh","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-82s","title":"Lock down sanitized Zep auth failures in graph build API","description":"Add route-level regression coverage for upstream issue #139 so /api/graph/build persists a clean actionable auth failure into task/project state instead of leaking raw Zep traceback text.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T11:42:42Z","created_by":"Codex","updated_at":"2026-03-11T11:43:42Z","closed_at":"2026-03-11T11:43:42Z","close_reason":"Added route-level regression coverage for sanitized Zep auth failures in graph build task/project state","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-wfe","title":"Normalize stale upstream issue triage coverage notes","description":"Several still-open upstream issues (for example #110, #99, #92, #46, #52, #14) are already covered locally by landed fixes/docs, but docs/upstream-triage.md does not record those mappings yet. Refresh the upstream snapshots, annotate which open issues are already addressed locally versus still actionable, and keep the machine-readable intake/triage aligned so future evolve passes do not re-triage stale items.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T11:39:38Z","created_by":"Codex","updated_at":"2026-03-11T11:40:21Z","closed_at":"2026-03-11T11:40:21Z","close_reason":"Refreshed open/full upstream snapshots and recorded which stale open upstream issues are already covered locally in docs/upstream-triage.md.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-n16","title":"Document and verify direct OpenAI-compatible backend setup","description":"Verify that MiroFish can run directly against OpenAI/Codex-compatible backends without any LLM_PROVIDER flag, add regression coverage for /api/graph/config/status so the backend exposes non-sensitive env-alias diagnostics, and document the verification flow in the README files for OpenAI/Codex/DashScope Coding Plan setups.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T11:37:08Z","created_by":"Codex","updated_at":"2026-03-11T11:38:04Z","closed_at":"2026-03-11T11:38:04Z","close_reason":"Verified the direct OpenAI/Codex-compatible backend path, added regression coverage for /api/graph/config/status alias reporting, and documented how to confirm active LLM_* vs OPENAI_* env sources via /health and /api/graph/config/status.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-eq3","title":"Surface actionable Zep auth errors in graph build tasks","description":"Upstream issue #139 still surfaces graph-build failures as raw tracebacks in task polling/UI. Classify common non-retryable Zep auth failures (especially 401/unauthorized/invalid API key), return a concise user-facing message, and add regression coverage without changing successful build behavior.","status":"closed","priority":2,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T11:22:41Z","created_by":"Codex","updated_at":"2026-03-11T11:23:47Z","closed_at":"2026-03-11T11:23:47Z","close_reason":"Graph build task failures now map common Zep auth errors to actionable user-facing messages, keep tracebacks in logs only, and have regression coverage in backend/tests/test_graph_builder.py.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-qoo","title":"Design true simulation checkpoint/resume beyond Step 3 reattach","description":"Follow-up to mirofish-1n5. The Step 3 UI now reattaches to existing run status/timeline instead of force-restarting on mount, but real checkpoint/resume after a backend crash, provider quota exhaustion, or intentional stop still is not implemented. Backend/runtime work would need durable simulation checkpoints, resumable OASIS process state, and explicit UX around partial reruns versus continuation.","notes":"2026-03-12: Landed a new repo-native recovery UX mitigation via child issue mirofish-n2rx. Step2EnvSetup.vue now surfaces a saved Step 3 recovery card whenever the current simulation already has replayable run state, so users can reopen the replay/restart route directly from Step 2 after fixing quota/API-key problems instead of going through history first. Validated with npm --prefix frontend test and npm --prefix frontend run build. True mid-run checkpoint/resume is still not implemented.\n2026-03-12: Landed another repo-native recovery UX mitigation via child issue mirofish-31if. Step5Interaction.vue now surfaces the same direct Step 3 replay/restart route whenever the interview environment is offline but the current simulation still has replayable state, so users can reopen the prepared run from the Step 5 workspace instead of navigating back manually. Added focused state coverage in frontend/tests/step5Recovery.test.mjs and revalidated with npm --prefix frontend test plus npm --prefix frontend run build. True mid-run checkpoint/resume is still not implemented.","status":"open","priority":2,"issue_type":"task","owner":"codex@local","created_at":"2026-03-11T11:20:29Z","created_by":"Codex","updated_at":"2026-03-12T03:05:54Z","dependencies":[{"issue_id":"mirofish-qoo","depends_on_id":"mirofish-1n5","type":"discovered-from","created_at":"2026-03-11T11:20:29Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-myk","title":"Normalize Step 5 interview errors across backend locales","description":"Follow-up from upstream issue #117 English language support. Step 5 interview error formatting currently matches Chinese-only backend phrases for closed-environment detection and timeout normalization. Extend the helper to recognize English backend variants too, keep localized UI messaging, and add frontend regression tests.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T11:15:45Z","created_by":"Codex","updated_at":"2026-03-11T11:16:21Z","closed_at":"2026-03-11T11:16:21Z","close_reason":"Extended Step 5 interview error normalization to match both Chinese and English backend error variants, with frontend regression coverage.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-0g4","title":"Harden graph API config validation for direct ontology/build calls","description":"Upstream issue #64 still has a direct-API gap after the frontend preflight work: callers that hit /api/graph/ontology/generate or /api/graph/build directly can still see generic runtime failures or inconsistent config errors when LLM/ZEP env vars are missing. Reuse the structured backend config validation payload in the graph API itself, add regression coverage, and update triage so direct server deployments remain diagnosable even when the frontend is bypassed.","status":"closed","priority":2,"issue_type":"bug","owner":"codex@local","created_at":"2026-03-11T11:13:41Z","created_by":"Codex","updated_at":"2026-03-11T11:13:50Z","closed_at":"2026-03-11T11:13:50Z","close_reason":"Completed: graph ontology/build endpoints now return the same structured 503 config diagnostics as /api/graph/config/status, with regression coverage and triage notes for the direct-API issue #64 path.","dependencies":[{"issue_id":"mirofish-0g4","depends_on_id":"mirofish-303","type":"discovered-from","created_at":"2026-03-11T11:13:40Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-2ai","title":"Expose OpenAI-compatible backend alias status in diagnostics","description":"Direct OpenAI/Codex-compatible backend support is already implemented via LLM_* and OPENAI_* aliases, but the diagnostics/config summary do not show which alias set is active and the JA/KO READMEs still lag the EN/CN direct-setup guidance. Add a non-sensitive config summary field for active env aliases/backend mode, cover it with tests, and document the direct OpenAI-compatible path in the translated READMEs.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T11:08:54Z","created_by":"Codex","updated_at":"2026-03-11T11:09:45Z","closed_at":"2026-03-11T11:09:45Z","close_reason":"Added non-sensitive config-summary alias diagnostics for direct OpenAI-compatible backends and documented the same path in the JA/KO READMEs.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-poe","title":"Add configurable backend CORS settings without changing current defaults","description":"Safe follow-up from mirofish-58w / upstream PR #105. Add env-driven CORS configuration for /api/* responses, keep the existing permissive default for backward compatibility, add regression coverage, and document the direct OpenAI-compatible/Codex-compatible backend path alongside the new cross-origin knobs.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T10:59:20Z","created_by":"Codex","updated_at":"2026-03-11T11:00:23Z","closed_at":"2026-03-11T11:00:23Z","close_reason":"Added env-driven backend CORS controls with backward-compatible defaults, regression tests, and docs/.env examples for cross-origin OpenAI-compatible deployments.","dependencies":[{"issue_id":"mirofish-poe","depends_on_id":"mirofish-58w","type":"discovered-from","created_at":"2026-03-11T10:59:20Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-bai","title":"Harden report outline title readability fallback","description":"Upstream issue #138 showed the report planner can still return abstract, literary titles even after prompt tuning. Add a deterministic backend fallback that rewrites obviously abstract/generic titles to a requirement-anchored title and cover it with regression tests.","status":"closed","priority":2,"issue_type":"bug","owner":"codex@local","created_at":"2026-03-11T10:53:31Z","created_by":"Codex","updated_at":"2026-03-11T10:53:34Z","closed_at":"2026-03-11T10:53:34Z","close_reason":"Implemented deterministic report-title fallback for abstract LLM outlines and added regression coverage.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-lms","title":"Refresh upstream intake and review remaining unlanded PRs","description":"Evolve pass for 2026-03-11: refresh docs/upstream-* machine-readable snapshots, verify fork mirror visibility, and review remaining unlanded open PRs for safe cherry-pick decisions on top of the current branch state. Record concrete accept/reject reasons, starting with stale or superseded dependency/workflow PRs.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T10:47:16Z","created_by":"Codex","updated_at":"2026-03-11T10:49:04Z","closed_at":"2026-03-11T10:49:04Z","close_reason":"Refreshed upstream snapshots, re-exported beads state, and recorded current no-go/superseded decisions for open PRs #82, #100, #102, and #87.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-0p9","title":"Clarify supported backends and browser-refresh recovery behavior","description":"Document the current OpenAI-compatible backend support and the actual persistence/recovery model when users refresh or close the browser during/after runs. Scope: README/README-EN and small history-modal copy only, based on current behavior already implemented locally. Discovered from upstream question issues #69 and #21.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T10:34:33Z","created_by":"Codex","updated_at":"2026-03-11T10:35:22Z","closed_at":"2026-03-11T10:35:22Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-sim","title":"Add backend root health endpoint for deployment diagnostics","description":"Upstream issue #133 shows users treating backend 0.0.0.0:5001 returning 404 as a startup failure. Add a small non-sensitive root/health response so direct browser hits confirm the backend is alive and point users at the API surface, with regression coverage.","status":"closed","priority":2,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T10:14:32Z","created_by":"Codex","updated_at":"2026-03-11T10:15:55Z","closed_at":"2026-03-11T10:15:55Z","close_reason":"Added backend root/health diagnostics endpoints with lightweight regression coverage; refreshed upstream snapshots and triage notes for issue #133 follow-up.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-qd8","title":"Stabilize full upstream snapshot refresh under shell timeout","description":"The open-only sync refresh still completes, but `python3 scripts/sync_upstream_github.py --state all --fork-remote origin --timeout 15 --output ... --summary ...` can stall long enough that a 90-second shell timeout wrapper expires in this environment. Investigate where the process wedges after individual request timeouts are already bounded, and make the full-history refresh complete or fail fast deterministically.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T10:08:53Z","created_by":"Codex","updated_at":"2026-03-11T10:11:25Z","closed_at":"2026-03-11T10:11:25Z","close_reason":"Bounded concurrent hydration makes full upstream snapshot refresh complete within shell timeout","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-nco","title":"Export both OpenAI base-url aliases for standalone runners","description":"Standalone simulation runners already accept both OPENAI_BASE_URL and OPENAI_API_BASE_URL on input, but backend/scripts/llm_env.py only exports OPENAI_API_BASE_URL for downstream tooling. Harden the direct OpenAI/Codex-compatible backend path by exporting both aliases, add regression coverage, and refresh local triage notes if needed.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T10:05:04Z","created_by":"Codex","updated_at":"2026-03-11T10:08:53Z","closed_at":"2026-03-11T10:08:53Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-e32","title":"Constrain report planner titles to user-facing readable phrasing","description":"Upstream issue #138 reports unreadable, overly abstract report titles. The planning prompt in backend/app/services/report_agent.py currently biases the planner toward academic, high-abstraction 'future ecology' framing even for concrete user asks like predicting a game's target audience. Add low-risk prompt constraints and regression coverage so generated titles/section summaries stay close to the user's question, avoid overly academic metaphors, and prefer concise actionable phrasing.","notes":"2026-03-11: Tightened the report planner prompt so titles/summaries stay anchored to the user's simulation requirement, explicitly reject abstract/literary phrasing, and require concrete object naming for product/game/event/persona predictions. Added backend/tests/test_report_agent.py to capture the planning messages and verify the readability constraints are sent to the LLM, then wired that test into scripts/test_backend_lite.sh. Validation: cd backend \u0026\u0026 uv run pytest -q tests/test_report_agent.py tests/test_openai_compat_services.py; bash ./scripts/test_backend_lite.sh.","status":"closed","priority":2,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T09:57:10Z","created_by":"Codex","updated_at":"2026-03-11T09:58:36Z","closed_at":"2026-03-11T09:58:36Z","close_reason":"Tightened report-planner prompt constraints for readable, requirement-anchored titles and summaries; added regression coverage and folded it into the lightweight backend suite.","dependencies":[{"issue_id":"mirofish-e32","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T09:57:09Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-s4o","title":"Surface actionable Step 5 interview environment status","description":"Upstream open issues #37 and #43 still hit opaque Step 5 interview failures when the simulation environment is no longer alive or the interview budget is too small. Add a concrete frontend follow-up that preflights /api/simulation/env-status on the interaction screen, surfaces platform availability / closed-environment guidance before sending interview requests, and normalizes timeout/environment-closed backend messages into actionable user-facing copy with regression coverage.","status":"closed","priority":2,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T09:47:46Z","created_by":"Codex","updated_at":"2026-03-11T09:49:51Z","closed_at":"2026-03-11T09:49:51Z","close_reason":"Implemented","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-8eg","title":"Design and rebase graph backend abstraction for RAGflow support","description":"Follow-up from upstream PR #118 review. The mirrored branch is not safe to cherry-pick because it threads a second graph backend through graph build/read/delete and simulation preparation without targeted regression tests, assumes specific RAGflow API response shapes, and predates newer local config-validation and API-error hardening. If RAGflow support is still desired, re-scope it as a fresh backend abstraction task with tests around config validation, graph reads, simulation preparation, and failure paths.","status":"open","priority":2,"issue_type":"task","owner":"codex@local","created_at":"2026-03-11T09:39:03Z","created_by":"Codex","updated_at":"2026-03-11T09:39:03Z","dependencies":[{"issue_id":"mirofish-8eg","depends_on_id":"mirofish-z42","type":"discovered-from","created_at":"2026-03-11T09:39:02Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-apl","title":"Unblock simulation extra on Python 3.13 without Rust","description":"After vendoring backend/oasis and removing camel-oasis from the simulation manifests, uv sync --extra simulation --frozen is still blocked in this environment because camel-ai pulls tiktoken==0.7.0, which falls back to a source build on Python 3.13 and fails without a Rust compiler. Follow up by either constraining the supported Python version for the simulation extra, moving to a camel-ai/tiktoken combination with wheels for Python 3.13, or documenting/enforcing a Rust prerequisite for that optional path.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T09:34:46Z","created_by":"Codex","updated_at":"2026-03-11T09:39:03Z","closed_at":"2026-03-11T09:39:03Z","close_reason":"Added a repo-level setup guard for optional simulation dependencies so Python 3.13+ without rustc now fails fast with an actionable message, updated install docs/comments, and added regression coverage for the guard logic.","dependencies":[{"issue_id":"mirofish-apl","depends_on_id":"mirofish-0kl","type":"discovered-from","created_at":"2026-03-11T09:34:45Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-agd","title":"Tolerate GitHub rate limits in upstream sync","description":"The upstream intake script currently aborts an evolve pass when GitHub API requests hit rate limits even if same-day docs/upstream-* snapshots already exist. Make refresh reuse a fresh cached snapshot and regenerate the markdown summary instead of failing hard, so beads/evolve passes can keep working from machine-readable local state.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T09:17:50Z","created_by":"Codex","updated_at":"2026-03-11T09:18:06Z","closed_at":"2026-03-11T09:18:06Z","close_reason":"Implemented fresh-cache fallback for rate-limited upstream sync and validated it with unit tests plus open/all snapshot refresh commands.","dependencies":[{"issue_id":"mirofish-agd","depends_on_id":"mirofish-z42","type":"discovered-from","created_at":"2026-03-11T09:17:49Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-p4v","title":"Close remaining OpenAI alias gaps in runner/runtime paths","description":"Follow-up to the direct OpenAI/Codex-compatible backend support work. Fix remaining runtime paths that still ignore standard OPENAI_* aliases or surface project-specific-only error text. Scope for this pass: ensure parallel simulation boost fallback honors OPENAI_MODEL when no boost model is set, and align user-facing missing-key errors in generator/runtime paths with the accepted LLM_API_KEY / OPENAI_API_KEY aliases. Related to Objective 7 and upstream compatibility issues around OpenAI-compatible backends.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T08:40:35Z","created_by":"Codex","updated_at":"2026-03-11T08:43:14Z","closed_at":"2026-03-11T08:43:14Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-19i","title":"Document direct OpenAI/Codex-compatible backend setup examples","description":"Objective 7 is implemented in code, but quick-start materials still bury the practical setup for OpenAI/Codex-compatible gateways and Aliyun DashScope Coding Plan. Add explicit .env and README examples, note that no provider flag is required, and document OPENAI_API_BASE_URL / coding.dashscope.aliyuncs.com/v1 usage to reduce repeated upstream compatibility questions such as #110.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T08:17:30Z","created_by":"Codex","updated_at":"2026-03-11T08:22:50Z","closed_at":"2026-03-11T08:22:50Z","close_reason":"Added explicit OpenAI/Codex-compatible and DashScope Coding Plan setup examples to .env.example and both READMEs.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-303","title":"Investigate upstream issue #64 upload 500 reports on server deployments","description":"The refreshed upstream snapshots now show that issue #64 reproduces across TXT/MD/PDF uploads and the discussion points at two likely classes of failure: older encoding-path crashes and server-side/model/Zep configuration failures during ontology generation. Use the new body/comment context to verify whether this branch already resolves the common cases (#124, encoding fallbacks, OpenAI-compatible JSON fallback) and identify any remaining reproducible server-side 500 path.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T08:12:17Z","created_by":"Codex","updated_at":"2026-03-11T08:15:37Z","closed_at":"2026-03-11T08:15:37Z","close_reason":"Completed: added backend config preflight diagnostics and preserved structured API errors in the frontend so missing LLM/Zep configuration no longer looks like a generic upload/build 500","dependencies":[{"issue_id":"mirofish-303","depends_on_id":"mirofish-bay","type":"discovered-from","created_at":"2026-03-11T08:12:16Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-bay","title":"Enrich upstream issue intake with body/comment summaries","description":"Current docs/upstream-*-state.json snapshots include issue and PR metadata but omit issue bodies and comment previews, which blocks practical triage of open upstream bugs such as #64. Extend scripts/sync_upstream_github.py to persist compact machine-readable body/comment summaries and add regression coverage.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T08:08:24Z","created_by":"Codex","updated_at":"2026-03-11T08:12:26Z","closed_at":"2026-03-11T08:12:26Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-8ru","title":"Refresh upstream snapshots and mirror missing open PR refs","description":"Continue the evolve loop by refreshing machine-readable upstream issue/PR summaries, mirroring any remaining review-relevant open upstream PR heads into origin for visibility, and reconciling triage notes with the current safe-to-land status.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T08:04:57Z","created_by":"Codex","updated_at":"2026-03-11T08:07:08Z","closed_at":"2026-03-11T08:07:08Z","close_reason":"Refreshed upstream snapshots, mirrored remaining open PR refs into origin, and improved snapshot metadata for deterministic future mirroring.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-9qd","title":"Automate upstream PR mirror status and reconcile stale review tasks","description":"Extend the upstream GitHub intake workflow so it can emit machine-readable fork mirror status for upstream pull requests, use that to mirror any missing clean PR refs that are still relevant, and reconcile stale beads tasks discovered during the refresh pass (for example upstream PR #120 is now closed).","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T07:55:18Z","created_by":"Codex","updated_at":"2026-03-11T07:58:39Z","closed_at":"2026-03-11T07:58:39Z","close_reason":"Added fork mirror-status annotations to upstream sync artifacts, mirrored the remaining clean non-main open PR refs into origin, refreshed the summaries, and closed the stale PR #120 review task.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-z42","title":"Review upstream PR #118 RAGflow backend support","description":"Evaluate 666ghj/MiroFish#118 now that its head is mirrored locally. The PR is mergeable but large and changes graph APIs, config, simulation services, and docs to support a RAGflow backend, so it needs a dedicated design/risk review before any subset is landed.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T07:43:56Z","created_by":"Codex","updated_at":"2026-03-11T09:39:03Z","closed_at":"2026-03-11T09:39:03Z","close_reason":"Reviewed upstream PR #118 and determined it is not safe to cherry-pick as-is: it introduces a second graph backend across multiple services without targeted regression tests and predates newer local config/API hardening. Split the remaining work into a dedicated backend-abstraction follow-up issue.","dependencies":[{"issue_id":"mirofish-z42","depends_on_id":"mirofish-0l1","type":"discovered-from","created_at":"2026-03-11T07:43:56Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-0l1","title":"Refresh upstream intake and mirror active review branches","description":"Refresh docs/upstream-* machine-readable snapshots from 666ghj/MiroFish, reconcile counts/triage notes, and mirror any still-relevant open upstream PR branches into the fork for review visibility.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T07:41:07Z","created_by":"Codex","updated_at":"2026-03-11T07:43:58Z","closed_at":"2026-03-11T07:43:58Z","close_reason":"Refreshed upstream issue/PR snapshots, added PR mergeability metadata to the sync artifacts, mirrored upstream PR branches #105/#108/#118 into the fork, and split larger follow-up reviews into dedicated beads tasks.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-57e","title":"Evaluate upstream PR #119 English language support","description":"Assess 666ghj/MiroFish#119 against current branch, cherry-pick if low-risk enough after conflict resolution and validation, or record exact blockers/findings if not.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T07:23:21Z","created_by":"Codex","updated_at":"2026-03-11T07:26:48Z","closed_at":"2026-03-11T07:26:48Z","close_reason":"Evaluated upstream PR #119, landed a safe frontend/i18n subset locally, and recorded broader localization as follow-up work.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-dkb","title":"Review upstream PR #87 workflow action upgrades","description":"Inspect 666ghj/MiroFish#87, which appears to be a workflow-only GitHub Actions version bump. If the diff is still low-risk after comparison with already-landed PR #116/#103 changes, cherry-pick or document why it is superseded.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T07:15:03Z","created_by":"Codex","updated_at":"2026-03-11T07:18:46Z","closed_at":"2026-03-11T07:18:46Z","close_reason":"Reviewed: upstream PR #87 is superseded locally because it would partially duplicate prior action bumps and regress this branch by dropping ARM64/cache workflow improvements.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-xvr","title":"Review and land upstream PR #103 Docker ARM64 workflow support","description":"Evaluate 666ghj/MiroFish#103. Current branch already has action-version updates, but the workflow still lacks multi-platform Docker build targets and GHA cache settings. If the diff stays workflow-only and compatible with the existing release pipeline, land the safe subset and mirror the branch to the fork.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T07:12:54Z","created_by":"Codex","updated_at":"2026-03-11T07:13:26Z","closed_at":"2026-03-11T07:13:26Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-5rs","title":"Review upstream PR #126 custom exceptions/config validation","description":"Evaluate 666ghj/MiroFish#126 against current local branch, land any low-risk subset that still adds value, and record any rejected/high-risk pieces with exact reasons.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T07:09:25Z","created_by":"Codex","updated_at":"2026-03-11T07:11:14Z","closed_at":"2026-03-11T07:11:14Z","close_reason":"Landed the low-risk subset of upstream PR #126: structured config validation helpers, safe numeric env parsing, and lightweight regression tests; skipped the large unused exception hierarchy.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-6yh","title":"Review upstream PR #82 dependency-only CVE patch","description":"Review 666ghj/MiroFish#82. The current PR only appends unstructured==0.18.18 to backend/requirements.txt, but this repo also uses backend/pyproject.toml and backend/uv.lock. Decide whether a coordinated dependency update is needed and validate impact before landing anything.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T07:07:22Z","created_by":"Codex","updated_at":"2026-03-11T07:21:47Z","closed_at":"2026-03-11T07:21:47Z","close_reason":"Reviewed upstream PR #82 and attempted a coordinated unstructured==0.18.18 lock refresh. Blocked because camel-oasis==0.2.5 transitively pins unstructured==0.13.7; opened follow-up mirofish-5as for the real remediation path.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-tzw","title":"Review upstream PR #120 layered service refactor","description":"Review 666ghj/MiroFish#120 separately from low-risk cherry-pick passes. The PR is a large service-layout refactor plus TODO docs, so it needs architectural review and targeted validation instead of a blind merge.","status":"closed","priority":2,"issue_type":"task","owner":"codex@local","created_at":"2026-03-11T07:07:22Z","created_by":"Codex","updated_at":"2026-03-11T07:55:18Z","closed_at":"2026-03-11T07:55:18Z","close_reason":"Upstream PR #120 was closed on March 11, 2026 without landing; the old review task is stale and no longer actionable as an open-PR evaluation item.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-6df","title":"Review upstream PR #126 config validation subset","description":"Assess 666ghj/MiroFish#126 for a safe subset worth landing locally. The PR is too large to cherry-pick wholesale, but the config validation and exception additions may contain small low-risk improvements if they can be isolated from broad behavior changes.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T07:04:17Z","created_by":"Codex","updated_at":"2026-03-11T07:04:44Z","closed_at":"2026-03-11T07:04:44Z","close_reason":"Reviewed upstream PR #126 and found no clear low-risk subset worth landing over the fork's current config layer; defer unless a narrower concrete requirement emerges.","dependencies":[{"issue_id":"mirofish-6df","depends_on_id":"mirofish-630","type":"discovered-from","created_at":"2026-03-11T07:04:16Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-630","title":"Land upstream PR #15 simulation failure status fix","description":"Cherry-pick or reimplement the small safe frontend fix from 666ghj/MiroFish#15 so Step3Simulation stops polling and surfaces an error when runner_status becomes failed instead of hanging in a running state.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T07:03:36Z","created_by":"Codex","updated_at":"2026-03-11T07:04:17Z","closed_at":"2026-03-11T07:04:17Z","close_reason":"Landed the safe frontend fix from upstream PR #15 locally, validated with a frontend production build, and updated the upstream triage notes.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-58w","title":"Review remaining risky subset of upstream PR #105 defaults","description":"Follow-up to the safe error-handling subset from 666ghj/MiroFish#105. Evaluate whether to adopt the remaining behavior-changing pieces: default DEBUG=False, non-static SECRET_KEY generation, and stricter CORS defaults/configuration. These need deployment-compatibility review and should not be landed blind.","notes":"2026-03-11: Landed safe follow-up issue mirofish-poe. Backend CORS is now configurable via CORS_ALLOWED_ORIGINS / CORS_ALLOW_METHODS / CORS_ALLOW_HEADERS while preserving the existing permissive default, with regression tests and docs/.env examples. Remaining review scope on this parent is the behavior-changing DEBUG/SECRET_KEY defaults rather than CORS configurability.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T07:01:33Z","created_by":"Codex","updated_at":"2026-03-11T12:20:25Z","closed_at":"2026-03-11T12:20:25Z","close_reason":"Reviewed upstream PR #105 defaults: landed DEBUG=false by default and generated fallback SECRET_KEY with tests/docs, while intentionally preserving permissive default CORS origins for backward compatibility.","dependencies":[{"issue_id":"mirofish-58w","depends_on_id":"mirofish-jyo","type":"discovered-from","created_at":"2026-03-11T07:01:33Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-xyr","title":"Land remaining safe upstream docs PRs","description":"Evaluate still-open upstream docs-only PRs (#112 Korean README, #113 Japanese README) and cherry-pick the safe ones into the fork branch, then validate docs tree and update triage.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T06:55:50Z","created_by":"Codex","updated_at":"2026-03-11T06:57:13Z","closed_at":"2026-03-11T06:57:13Z","close_reason":"Landed upstream docs PR #112 and #113 locally, refreshed upstream machine-readable snapshots, and recorded #114 as already superseded locally.","dependencies":[{"issue_id":"mirofish-xyr","depends_on_id":"mirofish-49x","type":"discovered-from","created_at":"2026-03-11T06:55:49Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-3sb","title":"Land upstream PR #104 configurable Vite API proxy target","description":"Cherry-pick or reimplement 666ghj/MiroFish#104 so frontend/vite.config.js reads VITE_API_BASE_URL instead of hardcoding localhost:5001, and document the env var in .env.example. Validate with frontend build.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T06:53:39Z","created_by":"Codex","updated_at":"2026-03-11T06:54:37Z","closed_at":"2026-03-11T06:54:37Z","close_reason":"Implemented and validated with frontend build","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-ba6","title":"Track lightweight backend validation path","description":"Validation follow-up: `cd backend \u0026\u0026 uv run pytest -q` currently fails in this environment while resolving/building heavyweight dependencies, including `tiktoken`, because no Rust compiler is available. Add or document a lighter CI/local validation path for targeted backend unit tests that does not require full CUDA-heavy dependency installation.","status":"closed","priority":2,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T06:33:31Z","created_by":"Codex","updated_at":"2026-03-11T06:49:40Z","closed_at":"2026-03-11T06:49:40Z","close_reason":"Added a repo-native lightweight backend validation path via scripts/test_backend_lite.sh, npm run test:backend:lite, README docs, and verified it passes.","dependencies":[{"issue_id":"mirofish-ba6","depends_on_id":"mirofish-49x","type":"discovered-from","created_at":"2026-03-11T06:33:30Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-18v.3","title":"Mirror selected upstream branches into fork for visibility","description":"Push actively reviewed upstream PR branches into the fork when they are under active review so the fork has branch-level visibility without broad merges.","status":"closed","priority":2,"issue_type":"task","owner":"codex@local","created_at":"2026-03-11T06:28:08Z","created_by":"Codex","updated_at":"2026-03-11T06:29:51Z","closed_at":"2026-03-11T06:29:51Z","close_reason":"Pushed mirror/upstream-pr-115, mirror/upstream-pr-122, mirror/upstream-pr-124, and mirror/upstream-pr-127 to the fork","dependencies":[{"issue_id":"mirofish-18v.3","depends_on_id":"mirofish-18v","type":"parent-child","created_at":"2026-03-11T06:28:08Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-6f45","title":"Tighten README-RU parity for direct backend setup and runtime caveats","description":"README-RU.md already documents the direct OpenAI-compatible backend path, but it still omits several current repo-native instructions from README-EN/README.md: the Python 3.13 + Rust caveat for npm run setup:backend:simulation, first-run sizing/timeouts guidance, browser refresh/runtime-session behavior, and the lightweight backend validation path. Land those safe docs-only updates and link them back to the localization/backend-compat umbrellas.","status":"closed","priority":3,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T01:27:21Z","created_by":"Codex","updated_at":"2026-03-12T01:28:05Z","closed_at":"2026-03-12T01:28:05Z","close_reason":"Completed","dependencies":[{"issue_id":"mirofish-6f45","depends_on_id":"mirofish-hj9","type":"discovered-from","created_at":"2026-03-12T01:27:20Z","created_by":"Codex","metadata":"{}"},{"issue_id":"mirofish-6f45","depends_on_id":"mirofish-pfbl","type":"discovered-from","created_at":"2026-03-12T01:27:20Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-mqrj","title":"Forward config-generator substep progress through prepare flow","description":"SimulationConfigGenerator.generate_config() already supports a progress callback, but SimulationManager.prepare_simulation() never passes one through. As a result, prepare-task status only shows coarse config-generation checkpoints instead of the generator's detailed per-substep progress. Wire the existing callback through with stable progress scaling and add regression coverage for the emitted event stream.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T01:24:52Z","created_by":"Codex","updated_at":"2026-03-12T01:25:20Z","closed_at":"2026-03-12T01:25:20Z","close_reason":"Forwarded SimulationConfigGenerator substep progress through prepare_simulation with stable 30-65% scaling and added regression coverage for the English prepare event stream.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-noso","title":"Localize InterviewAgents summary fallback in English mode","description":"Follow-up from partial upstream PR #119 coverage. backend/app/services/zep_tools.py still returns a Chinese-only fallback summary when interview-summary generation raises, even with X-Locale=en. Add an English fallback string with regression coverage and reconcile upstream triage notes after landing.","status":"closed","priority":3,"issue_type":"bug","owner":"codex@local","created_at":"2026-03-12T01:24:10Z","created_by":"Codex","updated_at":"2026-03-12T01:24:18Z","closed_at":"2026-03-12T01:24:18Z","close_reason":"False alarm: backend/app/services/zep_tools.py already returns an English fallback summary in the exception path, and regression coverage already exists in backend/tests/test_zep_tools_i18n.py.","dependencies":[{"issue_id":"mirofish-noso","depends_on_id":"mirofish-pfbl","type":"discovered-from","created_at":"2026-03-12T01:24:10Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-kn6f","title":"Localize README-RU quick start and direct backend docs","description":"README-RU.md currently exists as the safe repo-native Russian docs subset, but its quick-start, OpenAI-compatible backend, and verification guidance are still mostly English. Translate that subset into Russian while preserving the current repo-native setup split (`setup:core` / `setup:all` vs optional `setup:backend:simulation`) and direct `OPENAI_*` / Codex-compatible backend verification flow.","status":"closed","priority":3,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-12T00:25:02Z","created_by":"Codex","updated_at":"2026-03-12T00:26:05Z","closed_at":"2026-03-12T00:26:05Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-pfbl","title":"Design stronger locale enforcement for model-generated backend content","description":"Follow-up from mirofish-1nh. The remaining localization drift is mostly model-generated content (for example report prose, agent bios/personas, ontology summaries, and simulation narratives) rather than deterministic hardcoded runtime strings. Future work should focus on prompt-level locale contracts, post-generation validation/repair, and selective retry/fallback rules instead of ad-hoc hardcoded translations.","notes":"2026-03-12: Closed child issue mirofish-7aht after adding translated README parity for the direct Codex/OpenAI-compatible backend capability matrix in README-RU.md, README-KO.md, and README-JA.md. The remaining parent gap is unchanged: model-generated locale enforcement and post-generation repair/retry for backend content still need a reproducible implementation pass beyond deterministic/docs coverage.","status":"open","priority":3,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T22:53:06Z","created_by":"Codex","updated_at":"2026-03-12T03:37:04Z","dependencies":[{"issue_id":"mirofish-pfbl","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T22:53:06Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-gezp","title":"Localize Report Agent chat system prompt","description":"Follow-up from mirofish-1nh. backend/app/services/report_agent.py still builds the report-chat system prompt from a Chinese-only CHAT_SYSTEM_PROMPT_TEMPLATE even when locale=en, so English-mode Step 4/assistant chat starts from Chinese instructions, tool labels, and style guidance before any tool call. Add an English template or locale-aware builder for the deterministic chat prompt only, add focused regression coverage in backend/tests/test_report_agent.py, and reconcile mirofish-1nh notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T22:48:57Z","created_by":"Codex","updated_at":"2026-03-11T22:50:06Z","closed_at":"2026-03-11T22:50:06Z","close_reason":"Localized the Report Agent chat system prompt for locale=en, added focused regression coverage in backend/tests/test_report_agent.py, and passed targeted pytest, compileall, and scripts/test_backend_lite.sh.","dependencies":[{"issue_id":"mirofish-gezp","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T22:48:56Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-4q4g","title":"Localize ReportAgent empty-response fallback section copy","description":"Follow-up from mirofish-1nh. backend/app/services/report_agent.py still falls back to a Chinese-only deterministic section body when the LLM returns empty content (\"本章节生成失败:LLM 返回空响应,请稍后重试\") even when locale=en. Route that fixed fallback text through the existing locale helper, add focused regression coverage in backend/tests/test_report_agent.py, and reconcile mirofish-1nh notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T22:41:30Z","created_by":"Codex","updated_at":"2026-03-11T22:43:13Z","closed_at":"2026-03-11T22:43:13Z","close_reason":"Already covered: ReportAgent empty-response fallback is locale-aware and backend/tests/test_report_agent.py already exercises the English fallback path.","dependencies":[{"issue_id":"mirofish-4q4g","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T22:41:29Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-8zj6","title":"Localize report section-generation prompt scaffolding","description":"Follow-up from mirofish-1nh / upstream PR #119. backend/app/services/report_agent.py still builds SECTION_SYSTEM_PROMPT_TEMPLATE and SECTION_USER_PROMPT_TEMPLATE from Chinese-only deterministic scaffolding even when locale=en, so English-mode section generation starts from Chinese instructions/examples/tool-usage guidance before the later ReACT loop messages localize. Replace those fixed templates with locale-aware prompt builders, add focused regression coverage in backend/tests/test_report_agent.py, and reconcile the parent notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T22:35:29Z","created_by":"Codex","updated_at":"2026-03-11T22:36:38Z","closed_at":"2026-03-11T22:36:38Z","close_reason":"ReportAgent now builds locale-aware section system/user prompt scaffolding for locale=en, with focused regression coverage in backend/tests/test_report_agent.py and passing lightweight backend validation.","dependencies":[{"issue_id":"mirofish-8zj6","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T22:35:28Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-dsee","title":"Localize ReportAgent tool descriptions for English prompts","description":"Follow-up from mirofish-1nh / upstream PR #119. backend/app/services/report_agent.py still injects Chinese-only tool descriptions and parameter help into English-mode tool prompts via TOOL_DESC_* and _define_tools(), which leaks zh scaffolding into English report generation. Add locale-aware tool descriptions/parameter labels, cover the English prompt surface in backend/tests/test_report_agent.py, and reconcile the parent localization notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T22:30:36Z","created_by":"Codex","updated_at":"2026-03-11T22:33:50Z","closed_at":"2026-03-11T22:33:50Z","close_reason":"Localized ReportAgent tool descriptions and parameter help for locale=en, added focused regression coverage in backend/tests/test_report_agent.py, and passed targeted plus lightweight backend validation.","dependencies":[{"issue_id":"mirofish-dsee","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T22:30:36Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-3ua2","title":"Localize simulation config generator English output instructions","description":"Follow-up from mirofish-1nh / upstream PR #119. backend/app/services/simulation_config_generator.py already switches prompt scaffolding into English, but the English time/event prompt builders still do not explicitly require English output for free-text fields such as reasoning, narrative_direction, and initial post content. Tighten those deterministic instructions only, add focused regression coverage in backend/tests/test_openai_compat_services.py, and reconcile the parent localization notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T22:21:42Z","created_by":"Codex","updated_at":"2026-03-11T22:22:15Z","closed_at":"2026-03-11T22:22:15Z","close_reason":"Localized SimulationConfigGenerator English prompt instructions for free-text output fields, added focused regression coverage, and passed targeted pytest plus compileall.","dependencies":[{"issue_id":"mirofish-3ua2","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T22:21:41Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-bo9u","title":"Localize failed report progress stage label","description":"Scoped follow-up to mirofish-1nh. backend/app/api/report.py translates pending/planning/generating/completed stage prefixes for persisted report progress payloads but leaves stage='failed' untranslated, so locale=en can still surface a raw [failed] prefix in task/progress polling. Add the failed-stage English label in the report progress translator, cover it in backend/tests/test_report_api_i18n.py, and reconcile the parent localization notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T22:19:29Z","created_by":"Codex","updated_at":"2026-03-11T22:19:51Z","closed_at":"2026-03-11T22:19:51Z","close_reason":"Report progress polling now translates the failed stage prefix to English in backend/app/api/report.py, with focused regression coverage in backend/tests/test_report_api_i18n.py and compile validation.","dependencies":[{"issue_id":"mirofish-bo9u","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T22:19:28Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-bev6","title":"Localize simulation config generator progress labels","description":"Scoped follow-up to mirofish-1nh. backend/app/services/simulation_config_generator.py still has deterministic progress labels in _format_config_generation_status/_format_config_generation_progress that return Chinese-only strings for locale=en during agent config generation. Route those fixed strings through the existing locale helper, add focused regression coverage in backend/tests/test_openai_compat_services.py, and reconcile the parent localization notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T22:18:46Z","created_by":"Codex","updated_at":"2026-03-11T22:19:29Z","closed_at":"2026-03-11T22:19:29Z","close_reason":"No code change needed: simulation_config_generator progress helpers were already localized on the current branch; replaced with sharper child issue for report failed-stage progress translation.","dependencies":[{"issue_id":"mirofish-bev6","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T22:18:45Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-8d5w","title":"Localize rule-based profile fallback copy in OasisProfileGenerator","description":"Follow-up from mirofish-1nh. backend/app/services/oasis_profile_generator.py still emits deterministic English-only bios, personas, profession fallbacks, and interested-topic labels from _generate_profile_rule_based() whenever the LLM path is unavailable or disabled. Route those rule-based fallback strings through the existing locale helper, keep gender/country semantics unchanged, add focused regression coverage in backend/tests/test_openai_compat_services.py, and reconcile the parent localization notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T22:16:13Z","created_by":"Codex","updated_at":"2026-03-11T22:17:00Z","closed_at":"2026-03-11T22:17:00Z","close_reason":"Localized OasisProfileGenerator rule-based fallback copy for zh/en outputs, added focused regression coverage in backend/tests/test_openai_compat_services.py, and validated with targeted pytest plus compileall.","dependencies":[{"issue_id":"mirofish-8d5w","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T22:16:13Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-kcba","title":"Localize simulation-config unknown fallback labels","description":"Scoped follow-up to mirofish-1nh. backend/app/services/simulation_config_generator.py still hardcoded the English fallback label 'Unknown' in deterministic entity summaries, event poster defaults, and generated agent-config metadata, which leaked mixed-language placeholders in zh mode and kept locale-specific backend config output inconsistent. Route those unknown fallbacks through the locale helper, add focused regression coverage in backend/tests/test_openai_compat_services.py, and reconcile the parent localization notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T22:11:32Z","created_by":"Codex","updated_at":"2026-03-11T22:12:22Z","closed_at":"2026-03-11T22:12:22Z","close_reason":"Localized simulation_config_generator unknown fallback labels through the locale helper and added focused regression coverage in backend/tests/test_openai_compat_services.py.","dependencies":[{"issue_id":"mirofish-kcba","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T22:11:32Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-q5jt","title":"Align JA/KO OpenAI-compatible config examples with backend alias support","description":"Japanese and Korean README variants documented direct OPENAI_* alias support in prose but their example blocks omitted OPENAI_BASE_URL even though the backend accepts it and other README variants already show it. Add the missing alias examples, validate the direct OpenAI-compatible config-status path with backend tests, and keep this small docs/verification slice tracked in beads.","status":"closed","priority":3,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T22:06:50Z","created_by":"Codex","updated_at":"2026-03-11T22:06:58Z","closed_at":"2026-03-11T22:06:58Z","close_reason":"Added the missing OPENAI_BASE_URL alias examples to README-JA.md and README-KO.md, refreshed upstream issue/PR intake snapshots, and revalidated direct OpenAI-compatible config-status plus sync tests.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-1smc","title":"Localize zep_tools English fallback labels in report output","description":"Follow-up from mirofish-1nh / upstream PR #119. backend/app/services/zep_tools.py still leaked Chinese fallback labels into otherwise English report-facing output in two deterministic paths: Panorama historical fact ranges used the Chinese Unknown label when valid/invalid timestamps were missing, and InsightForge entity summaries fell back to the Chinese Entity label when no custom label existed. Localize only those fallback strings, add focused regression coverage in backend/tests/test_zep_tools_i18n.py, and reconcile the parent notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T22:01:41Z","created_by":"Codex","updated_at":"2026-03-11T22:03:58Z","closed_at":"2026-03-11T22:03:35Z","close_reason":"Localized zep_tools English fallback labels in Panorama historical timestamps and InsightForge default entity types, with focused regression coverage in backend/tests/test_zep_tools_i18n.py.","dependencies":[{"issue_id":"mirofish-1smc","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T22:01:41Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-mj8p","title":"Clarify direct OpenAI-compatible config-status warning semantics","description":"The direct OPENAI_* verification path is already implemented and working, but npm run check:backend-config in a local dev shell can still emit a non-fatal generated SECRET_KEY warning when SECRET_KEY is unset. Document that expected warning in the README verification sections so Codex/OpenAI-compatible backend checks are easier to interpret.","status":"closed","priority":3,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T21:54:08Z","created_by":"Codex","updated_at":"2026-03-11T21:54:50Z","closed_at":"2026-03-11T21:54:50Z","close_reason":"Documented the expected temporary SECRET_KEY warning in every README verification section after re-verifying the direct OPENAI_* config-status path under a clean shell.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-moan","title":"Localize graph-builder worker progress messages","description":"Follow-up from mirofish-1nh / upstream PR #119. backend/app/services/graph_builder.py still emits the initial worker progress messages in Chinese only (build start, graph created, ontology set, chunk split, waiting for Zep, fetching graph info) before the API translation shim sees them. Localize these deterministic task-status messages at emission time using the service locale, add focused regression coverage in backend/tests/test_graph_builder.py, and reconcile upstream localization notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T21:46:19Z","created_by":"Codex","updated_at":"2026-03-11T21:47:49Z","closed_at":"2026-03-11T21:47:49Z","close_reason":"Localized GraphBuilderService worker task-progress messages for locale=en, added regression coverage in backend/tests/test_graph_builder.py, and validated with targeted pytest plus compileall.","dependencies":[{"issue_id":"mirofish-moan","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T21:46:19Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-td4b","title":"Localize English interview key-quote extraction","description":"Follow-up from mirofish-1nh / upstream PR #119. backend/app/services/zep_tools.py still extracted interview key quotes with Chinese-only sentence splitting and quote pairing, so locale=en interview summaries could return a full response but weak or empty key_quotes. Extend the deterministic quote extraction and rendering cleanup to handle English punctuation/quote marks, add focused regression coverage in backend/tests/test_zep_tools_i18n.py, and keep this as a repo-native partial localization pass instead of cherry-picking the upstream branch.","status":"closed","priority":3,"issue_type":"bug","owner":"codex@local","created_at":"2026-03-11T21:43:48Z","created_by":"Codex","updated_at":"2026-03-11T21:43:53Z","closed_at":"2026-03-11T21:43:53Z","close_reason":"Localized English interview key-quote extraction in zep_tools, added focused regression coverage, and validated with targeted pytest + compileall.","dependencies":[{"issue_id":"mirofish-td4b","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T21:43:48Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-esva","title":"Localize OasisProfileGenerator empty prompt fallbacks in English mode","description":"Follow-up from mirofish-1nh. backend/app/services/oasis_profile_generator.py builds English persona prompts, but when entity attributes or context are missing it still injects Chinese fallback placeholders such as '无' and '无额外上下文'. Localize those deterministic empty-value prompt wrappers for locale=en, add focused regression coverage, and reconcile the parent localization notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T21:32:17Z","created_by":"Codex","updated_at":"2026-03-11T21:33:13Z","closed_at":"2026-03-11T21:33:13Z","close_reason":"Localized OasisProfileGenerator English empty-state prompt fallbacks, added focused regression coverage, and passed lightweight backend validation.","dependencies":[{"issue_id":"mirofish-esva","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T21:32:17Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-9qwz","title":"Localize InsightForge sub-query prompt scaffolding","description":"Follow-up from mirofish-1nh. backend/app/services/zep_tools.py still builds InsightForge sub-query generation prompts and fallback sub-query templates in Chinese only, so locale=en can ask the LLM for English report content while still priming sub-question planning with Chinese instructions. Route that deterministic scaffolding through locale-aware templates, add focused regression coverage in backend/tests/test_zep_tools_i18n.py, and reconcile the parent localization notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T21:20:39Z","created_by":"Codex","updated_at":"2026-03-11T21:22:01Z","closed_at":"2026-03-11T21:22:01Z","close_reason":"Localized InsightForge sub-query prompt scaffolding for locale=en in backend/app/services/zep_tools.py, added focused regression coverage in backend/tests/test_zep_tools_i18n.py, and passed targeted plus lightweight backend validation.","dependencies":[{"issue_id":"mirofish-9qwz","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T21:20:39Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-n3u3","title":"Preserve explicit locale for SimulationIPCClient timeout errors","description":"Follow-up from mirofish-1nh. backend/app/services/simulation_ipc.py logs IPC timeout diagnostics with the client's explicit locale, but the raised TimeoutError still uses get_locale() instead of the client locale. That means direct callers outside Flask request context can still receive a Chinese timeout string even when they passed locale='en'. Reuse the client locale for the exception path, add focused regression coverage, and reconcile the parent localization notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T21:10:59Z","created_by":"Codex","updated_at":"2026-03-11T21:12:15Z","closed_at":"2026-03-11T21:12:15Z","close_reason":"SimulationIPCClient now resolves locale at send time unless explicitly pinned and reuses that locale for timeout exceptions, with focused regression coverage in backend/tests/test_simulation_service_i18n.py and lightweight backend validation.","dependencies":[{"issue_id":"mirofish-n3u3","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T21:10:58Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-yj8y","title":"Localize Zep graph memory manager stop_all shutdown log","description":"Follow-up from mirofish-1nh. backend/app/services/zep_graph_memory_updater.py still logs the final stop_all() shutdown line via get_locale(), which falls back to zh outside request context even when the active updater/session locale is en. Preserve an English updater locale for that deterministic shutdown log, add focused regression coverage, and reconcile the parent notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T21:08:51Z","created_by":"Codex","updated_at":"2026-03-11T21:09:37Z","closed_at":"2026-03-11T21:09:37Z","close_reason":"ZepGraphMemoryManager.stop_all now preserves the active updater locale for the final shutdown log, with focused regression coverage in backend/tests/test_openai_compat_services.py and validation via scripts/test_backend_lite.sh.","dependencies":[{"issue_id":"mirofish-yj8y","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T21:08:50Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-qazx","title":"Localize report outline prompts for English mode","description":"Follow-up from mirofish-1nh. backend/app/services/report_agent.py still builds outline-planning prompts from Chinese-first scaffolding even when locale=en, which biases report titles/summaries/section names back toward Chinese despite the later English output requirement. Add locale-aware prompt scaffolding for the deterministic planning prompt only, keep model-authored content out of scope, add focused regression coverage, and reconcile the parent localization issue notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T21:03:54Z","created_by":"Codex","updated_at":"2026-03-11T21:04:57Z","closed_at":"2026-03-11T21:04:57Z","close_reason":"Localized ReportAgent outline-planning prompt scaffolding for locale=en, added focused regression assertions in backend/tests/test_report_agent.py, and passed targeted pytest plus compileall validation.","dependencies":[{"issue_id":"mirofish-qazx","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T21:03:53Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-ku4m","title":"Localize report_agent LLM debug preview logs","description":"Scoped follow-up to mirofish-1nh. backend/app/services/report_agent.py still emits a raw Chinese-only debug line ('LLM响应: ...') for section-generation previews even when locale=en, bypassing the existing locale-aware report logging helpers. Route that preview through the locale-aware logger, add focused regression coverage in backend/tests/test_report_agent.py, and reconcile mirofish-1nh notes once landed.","notes":"Localized the report_agent LLM preview debug log through the existing locale-aware logger so locale=en no longer emits the raw Chinese-only 'LLM响应' preview during section generation. Added focused coverage in backend/tests/test_report_agent.py and validated with cd backend \u0026\u0026 uv run pytest -q tests/test_report_agent.py, python3 -m compileall backend/app/services/report_agent.py backend/tests/test_report_agent.py, and bash ./scripts/test_backend_lite.sh.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T20:57:02Z","created_by":"Codex","updated_at":"2026-03-11T20:58:18Z","closed_at":"2026-03-11T20:58:18Z","close_reason":"ReportAgent now localizes the section-generation LLM preview debug log for locale=en, with focused regression coverage in backend/tests/test_report_agent.py and backend-lite validation.","dependencies":[{"issue_id":"mirofish-ku4m","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T20:57:02Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-qkko","title":"Localize ZepGraphMemoryUpdater missing-key error","description":"Scoped follow-up to mirofish-1nh. backend/app/services/zep_graph_memory_updater.py accepts locale=\"en\" but raises the missing ZEP_API_KEY ValueError via get_locale() before startup, so English-mode callers can still receive a Chinese deterministic config error. Use the updater locale consistently, add focused regression coverage in backend/tests/test_openai_compat_services.py, and reconcile mirofish-1nh notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T20:53:45Z","created_by":"Codex","updated_at":"2026-03-11T20:55:09Z","closed_at":"2026-03-11T20:55:09Z","close_reason":"ZepGraphMemoryUpdater now uses the requested/request-scoped locale before raising the deterministic missing-ZEP_API_KEY error, with focused regression coverage in backend/tests/test_openai_compat_services.py and lightweight backend validation.","dependencies":[{"issue_id":"mirofish-qkko","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T20:53:45Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-qx5e","title":"Localize parallel simulation action replay diagnostics","description":"Follow-up from mirofish-1nh. backend/scripts/run_parallel_simulation.py still prints deterministic Chinese-only diagnostics when action replay reads fail or action-context enrichment fails, which can leak into CLI/runtime logs even in English-mode direct OpenAI-compatible setups. Route those fixed messages through the existing script locale helper, add focused regression coverage, and reconcile the parent localization notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T20:49:33Z","created_by":"Codex","updated_at":"2026-03-11T20:50:46Z","closed_at":"2026-03-11T20:50:46Z","close_reason":"Localized parallel simulation action replay failure diagnostics via shared script messages, added focused regression coverage in backend/tests/test_parallel_simulation_script.py, and passed targeted pytest plus scripts/test_backend_lite.sh.","dependencies":[{"issue_id":"mirofish-qx5e","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T20:49:33Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-b553","title":"Localize Zep paging helper diagnostics","description":"Follow-up from mirofish-1nh. backend/app/utils/zep_paging.py still emits deterministic hardcoded retry/pagination warning/error strings outside the shared backend i18n catalog, so locale=en can leak untranslated Zep node/edge paging diagnostics into control-plane logs and any surfaced debug output. Route those fixed strings through backend i18n/get_locale, add focused regression coverage, and reconcile the parent localization notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T20:28:57Z","created_by":"Codex","updated_at":"2026-03-11T20:31:00Z","closed_at":"2026-03-11T20:31:00Z","close_reason":"Localized deterministic Zep paging retry/pagination diagnostics with focused regression coverage and lightweight backend validation.","dependencies":[{"issue_id":"mirofish-b553","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T20:28:56Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-7m84","title":"Localize remaining SimulationConfigGenerator batch status strings","description":"Follow-up from mirofish-1nh. backend/app/services/simulation_config_generator.py still formats a few deterministic progress/status strings with Chinese-only text for agent-batch generation and initial-post assignment (for example 生成Agent配置..., Agent配置: 成功生成..., 初始帖子分配...). Route those through locale-aware helpers, add focused regression coverage, and reconcile the parent notes once landed.","status":"closed","priority":3,"issue_type":"bug","owner":"codex@local","created_at":"2026-03-11T20:26:49Z","created_by":"Codex","updated_at":"2026-03-11T20:27:15Z","closed_at":"2026-03-11T20:27:15Z","close_reason":"Already covered in the current tree: SimulationConfigGenerator batch-progress and initial-post assignment strings are already locale-aware and regression-tested, so this duplicate follow-up is unnecessary.","dependencies":[{"issue_id":"mirofish-7m84","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T20:26:48Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-uje9","title":"Localize remaining simulation and entity-reader diagnostic logs","description":"Follow-up from mirofish-1nh. backend/app/api/simulation.py still emits a few Chinese-only deterministic warning/error logs around task-status lookup, report lookup, and realtime profile/config file reads, and backend/app/services/zep_entity_reader.py still logs a Chinese-only entity-fetch failure string. backend/app/services/simulation_config_generator.py also still switches truncation-repair warnings manually instead of using shared i18n. Route these fixed strings through backend i18n, add focused regression coverage, and reconcile mirofish-1nh notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T20:22:03Z","created_by":"Codex","updated_at":"2026-03-11T20:24:08Z","closed_at":"2026-03-11T20:24:08Z","close_reason":"Localized the remaining deterministic simulation/report-lookup realtime file-read diagnostics plus entity-reader failure logs for locale=en, switched SimulationConfigGenerator truncation warnings to shared i18n, and validated with targeted pytest plus scripts/test_backend_lite.sh.","dependencies":[{"issue_id":"mirofish-uje9","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T20:22:03Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-1wif","title":"Localize graph API lifecycle logs","description":"Follow-up from mirofish-1nh. backend/app/api/graph.py still emits deterministic Chinese-only lifecycle/debug/logging strings during ontology generation and graph-build startup/completion (for example start banner, project creation, text extraction summary, LLM ontology call, build-task creation, and document parse failure warnings) even when X-Locale=en. Route those fixed log lines through backend i18n, add focused regression coverage, and reconcile the parent localization notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T20:18:06Z","created_by":"Codex","updated_at":"2026-03-11T20:19:36Z","closed_at":"2026-03-11T20:19:36Z","close_reason":"Localized graph API lifecycle logs for locale=en, added focused regression coverage in backend/tests/test_graph_upload_api.py, and passed targeted plus lightweight backend validation.","dependencies":[{"issue_id":"mirofish-1wif","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T20:18:06Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-ksbd","title":"Localize SimulationRunner state/history error logs","description":"Follow-up from mirofish-1nh. backend/app/services/simulation_runner.py still emits deterministic Chinese-only error logs when loading persisted run_state.json fails and when reading interview history from a platform database fails. Route those fixed log lines through backend i18n using the resolved locale, add focused regression coverage, and reconcile the parent localization issue notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T20:13:11Z","created_by":"Codex","updated_at":"2026-03-11T20:15:16Z","closed_at":"2026-03-11T20:15:16Z","close_reason":"Localized SimulationRunner run-state/interview-history error logs, fixed the interview-history locale-resolution bug, and passed focused plus lightweight backend validation.","dependencies":[{"issue_id":"mirofish-ksbd","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T20:13:10Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-i34w","title":"Localize simulation API request and prepare logs","description":"Follow-up from mirofish-1nh. backend/app/api/simulation.py still emits deterministic Chinese-only request/prepare lifecycle logs for entity reads and /prepare orchestration (for example graph entity fetch, prepare request start/check/result, preview entity count, and preview retry warnings) even when X-Locale=en. Route only those fixed API log lines through locale-aware helpers, add focused regression coverage in backend/tests/test_simulation_api_i18n.py, and reconcile the parent notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T20:08:19Z","created_by":"Codex","updated_at":"2026-03-11T20:09:31Z","closed_at":"2026-03-11T20:09:31Z","close_reason":"Localized deterministic simulation API request/prepare lifecycle logs for locale=en in backend/app/api/simulation.py, added focused regression coverage in backend/tests/test_simulation_api_i18n.py, and passed targeted plus lightweight backend validation.","dependencies":[{"issue_id":"mirofish-i34w","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T20:08:19Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-1jo3","title":"Localize SimulationManager lifecycle logs","description":"Follow-up from mirofish-1nh. backend/app/services/simulation_manager.py still emits deterministic Chinese-only lifecycle logs when creating a simulation and when simulation preparation succeeds or fails. Route those fixed messages through backend i18n, add focused regression coverage, and reconcile the parent localization notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T19:55:25Z","created_by":"Codex","updated_at":"2026-03-11T19:56:18Z","closed_at":"2026-03-11T19:56:18Z","close_reason":"Localized SimulationManager lifecycle logs for locale=en with focused regression coverage and lightweight backend validation.","dependencies":[{"issue_id":"mirofish-1jo3","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T19:55:25Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-oooh","title":"Localize Zep graph memory updater runtime logs","description":"Follow-up from mirofish-1nh. backend/app/services/zep_graph_memory_updater.py still emits deterministic Chinese-only startup, queue, batch-send, retry, flush, and manager lifecycle logs even when locale=en. Route only those fixed control-plane messages through a local helper, add focused regression coverage in backend/tests/test_openai_compat_services.py, and reconcile mirofish-1nh notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T19:46:58Z","created_by":"Codex","updated_at":"2026-03-11T19:49:09Z","closed_at":"2026-03-11T19:49:09Z","close_reason":"Localized ZepGraphMemoryUpdater English runtime logs, added focused regression coverage in backend/tests/test_openai_compat_services.py, refreshed upstream intake artifacts, and passed targeted plus lightweight backend validation.","dependencies":[{"issue_id":"mirofish-oooh","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T19:46:57Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-xeaz","title":"Localize remaining OasisProfileGenerator deterministic progress logs","description":"Follow-up from mirofish-1nh. backend/app/services/oasis_profile_generator.py still has a few deterministic progress or fallback log/console strings that are not fully routed through the locale helper, so locale=en can still leak Chinese-first wording while generating profiles. Localize only those fixed strings, add focused regression coverage in backend/tests/test_openai_compat_services.py, and reconcile mirofish-1nh notes once landed.","status":"closed","priority":3,"issue_type":"bug","owner":"codex@local","created_at":"2026-03-11T19:46:31Z","created_by":"Codex","updated_at":"2026-03-11T19:46:58Z","closed_at":"2026-03-11T19:46:58Z","close_reason":"Inspected oasis_profile_generator and found no sharper remaining locale=en leak worth separate work; the next real deterministic i18n seam is tracked under a new zep_graph_memory_updater child issue instead.","dependencies":[{"issue_id":"mirofish-xeaz","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T19:46:31Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-iq4a","title":"Localize ZepEntityReader diagnostics","description":"Follow-up from mirofish-1nh. backend/app/services/zep_entity_reader.py still logs deterministic Chinese-only retry/fetch/filter diagnostics (retry attempts, node/edge fetch counts, filter start/summary, per-node edge fetch failures) even when locale=en. Thread locale into ZepEntityReader, localize only those fixed strings via backend i18n, add focused regression coverage, and reconcile the parent notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T19:43:03Z","created_by":"Codex","updated_at":"2026-03-11T19:44:32Z","closed_at":"2026-03-11T19:44:32Z","close_reason":"Localized ZepEntityReader retry/fetch/filter diagnostics for locale=en with focused regression coverage in backend/tests/test_zep_entity_reader.py; validated via targeted pytest, compileall, and scripts/test_backend_lite.sh.","dependencies":[{"issue_id":"mirofish-iq4a","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T19:43:03Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-swuv","title":"Localize profile generator deterministic Zep context scaffolding","description":"Follow-up from mirofish-1nh. backend/app/services/oasis_profile_generator.py still leaks Chinese-only deterministic strings in English mode: Zep lookup retry/warning logs, the mixed-search query template, and the fixed context section labels fed into profile generation. Localize only those fixed scaffolding strings via the existing locale-aware helpers, add focused regression coverage, and reconcile the parent localization notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T19:36:28Z","created_by":"Codex","updated_at":"2026-03-11T19:37:56Z","closed_at":"2026-03-11T19:37:56Z","close_reason":"Localized deterministic profile-generator Zep query/context scaffolding and logs for locale=en, added focused regression coverage, and passed lightweight backend validation.","dependencies":[{"issue_id":"mirofish-swuv","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T19:36:27Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-cifl","title":"Localize zep_tools report/search console diagnostics","description":"Follow-up from mirofish-1nh. backend/app/services/zep_tools.py still writes deterministic Chinese-only logger messages for graph search/local search, node/edge fetches, statistics, InsightForge, Panorama, and QuickSearch. Those messages surface in report console_log.txt and debugging endpoints even when X-Locale=en. Localize only the fixed control-plane log lines, add focused regression coverage in backend/tests/test_zep_tools_i18n.py, and reconcile mirofish-1nh notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T19:32:20Z","created_by":"Codex","updated_at":"2026-03-11T19:34:01Z","closed_at":"2026-03-11T19:34:01Z","close_reason":"Localized zep_tools report/search console diagnostics for locale=en with focused regression coverage and lightweight backend validation.","dependencies":[{"issue_id":"mirofish-cifl","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T19:32:20Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-3u34","title":"Localize graph batch and wait progress messages","description":"Follow-up to mirofish-1nh. GraphBuilderService still emits fixed Chinese-only batch-send and wait-loop progress messages during graph construction (batch upload retries, wait start/progress/timeout/completion/no-episode cases). Move those deterministic strings behind backend i18n, add focused regression coverage, and reconcile the parent localization tracker once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T19:28:50Z","created_by":"Codex","updated_at":"2026-03-11T19:30:11Z","closed_at":"2026-03-11T19:30:11Z","close_reason":"Graph build batch-send and wait-loop progress messages now use backend i18n in GraphBuilderService, with translation fallback coverage in backend/tests/test_graph_builder.py and backend/tests/test_graph_upload_api.py. Validated with targeted pytest, compileall, and scripts/test_backend_lite.sh.","dependencies":[{"issue_id":"mirofish-3u34","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T19:28:50Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-splj","title":"Localize remaining dual-platform simulation runtime status lines","description":"Scoped follow-up from mirofish-1nh. backend/scripts/run_parallel_simulation.py still emits a few deterministic Chinese-only status lines in simulation.log for shutdown/truncation/completion paths (for example received shutdown signal at round N, simulation loop complete elapsed/actions, and effective truncated rounds). Move those fixed strings into backend/scripts/llm_env.py, add focused regression coverage, and reconcile the parent localization notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T19:26:53Z","created_by":"Codex","updated_at":"2026-03-11T19:27:27Z","closed_at":"2026-03-11T19:27:27Z","close_reason":"Localized the remaining deterministic dual-platform shutdown/truncation/completion status lines via backend/scripts/llm_env.py, added focused runtime-string regression coverage in backend/tests/test_llm_env.py, and validated with backend pytest plus scripts/test_backend_lite.sh.","dependencies":[{"issue_id":"mirofish-splj","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T19:26:53Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-izgl","title":"Localize simulation prepare/start diagnostics","description":"Follow-up from mirofish-1nh. backend/app/api/simulation.py still emits deterministic Chinese-only diagnostic logs around prepare-state detection, auto-ready reconciliation, force restart cleanup, and graph-memory enablement. Route those fixed strings through backend i18n, add focused English-mode regression coverage, and reconcile the parent localization notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T19:18:55Z","created_by":"Codex","updated_at":"2026-03-11T19:20:12Z","closed_at":"2026-03-11T19:20:12Z","close_reason":"Localized deterministic simulation prepare/start diagnostics in backend/app/api/simulation.py via backend i18n, added focused English-mode log regression coverage in backend/tests/test_simulation_api_i18n.py, and validated with targeted pytest, compileall, and scripts/test_backend_lite.sh.","dependencies":[{"issue_id":"mirofish-izgl","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T19:18:54Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-4043","title":"Localize graph/report kickoff API messages","description":"Follow-up from mirofish-1nh. The initial success payloads from POST /api/graph/build and POST /api/report/generate still return fixed Chinese-only message strings (图谱构建任务已启动 / 报告生成任务已启动) even when X-Locale=en, despite existing i18n keys covering those states. Route both payloads through backend i18n, add focused regression tests, and reconcile the parent localization notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T18:57:50Z","created_by":"Codex","updated_at":"2026-03-11T18:59:11Z","closed_at":"2026-03-11T18:59:11Z","close_reason":"Confirmed graph kickoff localization was already covered; added focused report generate kickoff regression coverage for locale=en and passed backend validation.","dependencies":[{"issue_id":"mirofish-4043","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T18:57:50Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-7gv5","title":"Localize parallel simulation fallback interview diagnostics","description":"Follow-up from mirofish-1nh. backend/scripts/run_parallel_simulation.py still emits a few deterministic fallback interview diagnostics via inline Chinese-first literals instead of the shared script message catalog (for example unavailable-platform errors, no-environment failures, and per-platform agent lookup warnings). Route those fixed strings through backend/scripts/llm_env.py, add focused regression coverage, and reconcile the parent localization notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T18:55:07Z","created_by":"Codex","updated_at":"2026-03-11T18:56:15Z","closed_at":"2026-03-11T18:56:15Z","close_reason":"Localized deterministic parallel simulation fallback interview diagnostics through the shared script message catalog, added focused llm_env coverage, and passed uv run pytest -q tests/test_llm_env.py plus scripts/test_backend_lite.sh.","dependencies":[{"issue_id":"mirofish-7gv5","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T18:55:07Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-yyuk","title":"Localize zep_tools interview fallback profession and summary prompt wrappers","description":"Follow-up from mirofish-1nh. English-mode interview flows in backend/app/services/zep_tools.py still leak Chinese deterministic scaffolding in two places: twitter CSV profile loading hardcodes profession='未知', which then propagates into interview planner/question prompts, and _generate_interview_summary wraps interview excerpts with Chinese-only brackets/role delimiters before sending them to the LLM. Localize those deterministic fallback strings, add focused regression coverage, and reconcile the parent notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T18:52:24Z","created_by":"Codex","updated_at":"2026-03-11T18:53:12Z","closed_at":"2026-03-11T18:53:12Z","close_reason":"Localized zep_tools English interview fallback profession and summary prompt wrappers; added focused zep_tools i18n regression coverage and passed scripts/test_backend_lite.sh.","dependencies":[{"issue_id":"mirofish-yyuk","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T18:52:24Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-pft7","title":"Localize standalone simulation IPC response payloads","description":"Follow-up from mirofish-1nh. backend/scripts/run_reddit_simulation.py and backend/scripts/run_twitter_simulation.py still emit deterministic inline Chinese-first IPC response payload strings for the close-environment confirmation and unknown-command error path instead of using the shared script message catalog. Move those payloads behind backend/scripts/llm_env.py, add focused regression coverage, and reconcile the parent notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T18:49:25Z","created_by":"Codex","updated_at":"2026-03-11T18:49:52Z","closed_at":"2026-03-11T18:49:52Z","close_reason":"Localized standalone simulation IPC close/unknown-command payloads via backend/scripts/llm_env.py and validated with backend/tests/test_llm_env.py plus scripts/test_backend_lite.sh.","dependencies":[{"issue_id":"mirofish-pft7","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T18:49:24Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-5edt","title":"Localize retry helper diagnostics","description":"Follow-up from mirofish-1nh. backend/app/utils/retry.py still emits deterministic Chinese-only retry and terminal-failure log lines (sync, async, and RetryableAPIClient paths), which can surface during OpenAI-compatible backend failures even when locale=en. Route only those fixed diagnostics through backend i18n, add focused regression coverage, and reconcile the parent localization notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T18:46:11Z","created_by":"Codex","updated_at":"2026-03-11T18:47:10Z","closed_at":"2026-03-11T18:47:10Z","close_reason":"Localized retry helper diagnostics via backend i18n, added backend/tests/test_retry_i18n.py, and passed cd backend \u0026\u0026 uv run pytest -q tests/test_retry_i18n.py plus bash ./scripts/test_backend_lite.sh.","dependencies":[{"issue_id":"mirofish-5edt","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T18:46:11Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-27sy","title":"Localize simulation runner interview command logs","description":"Follow-up from mirofish-1nh. backend/app/services/simulation_runner.py still logs deterministic Chinese-only control-plane messages when sending single-agent, batch, global, and close-environment interview IPC commands. Localize only those fixed runner log lines via backend i18n, add focused regression coverage in backend/tests/test_simulation_service_i18n.py, and update the parent notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T18:35:21Z","created_by":"Codex","updated_at":"2026-03-11T18:36:09Z","closed_at":"2026-03-11T18:36:09Z","close_reason":"Localized SimulationRunner interview-command logs for locale=en, added focused regression coverage in backend/tests/test_simulation_service_i18n.py, and passed cd backend \u0026\u0026 uv run pytest -q tests/test_simulation_service_i18n.py, python3 -m compileall backend/app/services/simulation_runner.py backend/app/i18n.py, and bash ./scripts/test_backend_lite.sh.","dependencies":[{"issue_id":"mirofish-27sy","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T18:35:21Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-5w1u","title":"Localize profile generation progress and console output","description":"Follow-up from mirofish-1nh. backend/app/services/oasis_profile_generator.py still emits deterministic Chinese-only profile-generation progress, fallback logs, and console output during Step 2 profile generation (for example completed N/N, generated profile headers, fallback warnings, and saved-profile notices). Localize only those fixed control-plane strings for locale=en, add focused regression coverage, and reconcile the parent notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T18:29:29Z","created_by":"Codex","updated_at":"2026-03-11T18:30:37Z","closed_at":"2026-03-11T18:30:37Z","close_reason":"Localized deterministic OasisProfileGenerator progress/log/console output for locale=en, added focused regression coverage in backend/tests/test_openai_compat_services.py, and passed targeted plus lightweight backend validation.","dependencies":[{"issue_id":"mirofish-5w1u","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T18:29:28Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-idjq","title":"Localize remaining zep_tools interview fallback logs","description":"Follow-up from mirofish-1nh / upstream PR #119. backend/app/services/zep_tools.py still emits a few deterministic Chinese-only interview fallback logger messages when locale=en (agent selection/question/summary fallback paths), which leaves English-mode server logs mixed-language even though the returned payloads are localized. Route only those fixed log/fallback wrappers through locale-aware helpers, add focused regression coverage, and then reconcile the parent localization notes.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T18:26:06Z","created_by":"Codex","updated_at":"2026-03-11T18:27:05Z","closed_at":"2026-03-11T18:27:05Z","close_reason":"Localized remaining English-mode ZepTools interview fallback logger messages, added regression coverage in backend/tests/test_zep_tools_i18n.py, and passed focused plus lightweight backend validation.","dependencies":[{"issue_id":"mirofish-idjq","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T18:26:05Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-mk2s","title":"Localize simulation config generator diagnostics","description":"Follow-up from mirofish-1nh. backend/app/services/simulation_config_generator.py still emits deterministic Chinese-only correction/assignment diagnostics in English mode (for example agents_per_hour bounds fixes and initial-post poster fallback/assignment logs). Localize only those fixed control-plane strings and add focused regression coverage.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T18:22:56Z","created_by":"Codex","updated_at":"2026-03-11T18:23:50Z","closed_at":"2026-03-11T18:23:50Z","close_reason":"Localized deterministic SimulationConfigGenerator diagnostics for locale=en, added focused regression coverage in backend/tests/test_openai_compat_services.py, and passed targeted pytest plus scripts/test_backend_lite.sh.","dependencies":[{"issue_id":"mirofish-mk2s","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T18:22:55Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-7kdw","title":"Normalize Step 5 batch/all interview timeout errors","description":"Follow-up from mirofish-1nh. frontend/src/components/step5Profiles.js only normalizes single-interview timeout prefixes, but backend interview APIs can also return deterministic batch and all-agent timeout messages (for example 等待批量Interview响应超时 / Waiting for batch Interview response timed out / Waiting for all-agent Interview response timed out). Extend the frontend matcher so English mode consistently maps those backend timeouts to the localized step5 timeout UI, add focused regression coverage, and reconcile the parent localization issue notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T18:19:49Z","created_by":"Codex","updated_at":"2026-03-11T18:20:22Z","closed_at":"2026-03-11T18:20:22Z","close_reason":"Normalized Step 5 single/batch/global interview timeout errors in frontend/src/components/step5Profiles.js, added focused coverage in frontend/tests/step5Profiles.test.mjs, and validated with npm --prefix frontend test -- step5Profiles.test.mjs.","dependencies":[{"issue_id":"mirofish-7kdw","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T18:19:49Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-81e8","title":"Localize ZepToolsService deterministic log lines","description":"Follow-up from mirofish-1nh. backend/app/services/zep_tools.py still writes deterministic Chinese-only operational logs for search, fallback, graph reads, and interview orchestration even when locale=en. Localize only those fixed log strings via a small helper, add focused English-mode regression coverage for representative flows, and update the parent issue notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T18:15:47Z","created_by":"Codex","updated_at":"2026-03-11T18:17:47Z","closed_at":"2026-03-11T18:17:47Z","close_reason":"Localized deterministic ZepToolsService operational logs for locale=en across search fallback and interview/profile-loading paths, added focused log regression coverage in backend/tests/test_zep_tools_i18n.py, and passed targeted plus lightweight backend validation.","dependencies":[{"issue_id":"mirofish-81e8","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T18:15:46Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-5d4r","title":"Localize Report Agent chat scaffolding in English mode","description":"Follow-up from mirofish-1nh. backend/app/services/report_agent.py still injects Chinese-only deterministic scaffolding into the Report Agent chat path in locale=en, including the no-report placeholder, truncated-report marker, tool observation label/suffix, and related prompt glue. Localize only those fixed strings, add focused regression coverage, and update the parent issue notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T18:07:16Z","created_by":"Codex","updated_at":"2026-03-11T18:08:32Z","closed_at":"2026-03-11T18:08:32Z","close_reason":"Localized English Report Agent chat scaffolding, added focused regression coverage, and passed targeted plus lightweight backend validation.","dependencies":[{"issue_id":"mirofish-5d4r","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T18:07:16Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-vgv2","title":"Localize Simulation IPC client diagnostics","description":"Follow-up from mirofish-1nh. backend/app/services/simulation_ipc.py still writes deterministic Chinese-only IPC client log lines (command sent, response received, response parse failure, timeout) that can surface in simulation.log and Step 3 waiting_diagnostics even when locale=en. Thread locale into SimulationIPCClient, localize only those fixed strings via backend i18n, add focused regression coverage, and reconcile the parent localization notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T18:00:07Z","created_by":"Codex","updated_at":"2026-03-11T18:01:23Z","closed_at":"2026-03-11T18:01:23Z","close_reason":"Localized deterministic SimulationIPCClient diagnostics for locale=en, added focused IPC log coverage, hardened the direct OpenAI-compatible subprocess config-status test against inherited OPENAI_BASE_URL state, and passed targeted plus lightweight backend validation.","dependencies":[{"issue_id":"mirofish-vgv2","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T18:00:06Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-7tpe","title":"Localize graph-build task titles for English mode","description":"Follow-up from mirofish-1nh. backend/app/api/graph.py still creates graph-build tasks with a Chinese-only task_type/title string like '构建图谱: \u003cname\u003e', even when X-Locale=en. Localize only that deterministic task label, keep existing task ids/metadata unchanged, add focused regression coverage, and reconcile the parent localization issue notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T17:51:15Z","created_by":"Codex","updated_at":"2026-03-11T17:52:02Z","closed_at":"2026-03-11T17:52:02Z","close_reason":"Localized graph-build task titles for English mode via backend i18n, added translation coverage in backend/tests/test_graph_upload_api.py, and passed focused pytest plus compileall validation.","dependencies":[{"issue_id":"mirofish-7tpe","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T17:51:15Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-5p0","title":"Localize Step 2 simulation prepare progress messages","description":"Follow-up from mirofish-1nh. backend/app/services/simulation_manager.py still emits fixed Chinese-only progress callback messages during prepare_simulation() (for example connecting to Zep, reading nodes, profile save, config generation completed). Localize only those deterministic progress strings at the source via backend i18n, add focused service/API regression coverage, and reconcile the parent localization notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T17:35:52Z","created_by":"Codex","updated_at":"2026-03-11T17:37:17Z","closed_at":"2026-03-11T17:37:17Z","close_reason":"Localized deterministic Step 2 prepare progress messages at the source, added service/API regression coverage, and validated with focused pytest plus scripts/test_backend_lite.sh.","dependencies":[{"issue_id":"mirofish-5p0","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T17:35:52Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-ofn","title":"Localize file parser multi-document headers and failure markers","description":"Follow-up from mirofish-1nh. backend/app/utils/file_parser.py still emits Chinese-only deterministic wrappers like \"=== 文档 N: ... ===\" and \"(提取失败: ...)\" when combining multiple files, with no locale-aware path. Thread locale through the multi-file extraction helper, keep default zh behavior, add focused regression coverage for English output, and reconcile the parent localization issue notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T17:23:37Z","created_by":"Codex","updated_at":"2026-03-11T17:24:31Z","closed_at":"2026-03-11T17:24:31Z","close_reason":"Localized FileParser multi-document headers and extraction-failure markers, threaded optional locale through multi-file extraction helpers, and validated with uv run --project backend pytest -q backend/tests/test_backend_localized_errors.py backend/tests/test_simulation_service_i18n.py plus bash ./scripts/test_backend_lite.sh.","dependencies":[{"issue_id":"mirofish-ofn","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T17:23:36Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-5vo","title":"Localize graph/report task-start API messages","description":"Follow-up from mirofish-1nh. backend/app/api/graph.py and backend/app/api/report.py still return hardcoded Chinese task-start messages (\"图谱构建任务已启动\" / \"报告生成任务已启动\") in English mode even though the surrounding APIs are localized. Replace those deterministic strings with backend i18n keys, add focused regression coverage, and reconcile the parent localization issue notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T17:22:57Z","created_by":"Codex","updated_at":"2026-03-11T17:23:36Z","closed_at":"2026-03-11T17:23:36Z","close_reason":"No code change needed: the graph/report start endpoints already use i18n keys; the apparent Chinese strings were docstring examples, not live API payloads.","dependencies":[{"issue_id":"mirofish-5vo","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T17:22:57Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-2wk","title":"Localize simulation runner lifecycle logs in waiting diagnostics","description":"Follow-up from mirofish-1nh. backend/app/services/simulation_runner.py still writes deterministic lifecycle/error lines to simulation.log in Chinese only (for example start/completed/failed/platform completed/all completed/stop/cleanup messages). Those lines now surface in Step 3 waiting_diagnostics, so English mode still leaks Chinese runtime copy. Route the fixed runner log lines through backend i18n, add focused regression coverage, and reconcile the parent localization notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T17:15:07Z","created_by":"Codex","updated_at":"2026-03-11T17:20:29Z","closed_at":"2026-03-11T17:20:29Z","close_reason":"Localized SimulationRunner deterministic lifecycle/process-control log lines via backend i18n, added focused regression coverage, and validated with tests/test_simulation_runner_actions.py plus scripts/test_backend_lite.sh.","dependencies":[{"issue_id":"mirofish-2wk","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T17:15:06Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-0of","title":"Localize prepared simulation run instructions","description":"Follow-up from mirofish-1nh. backend/app/services/simulation_manager.py still returns Chinese-only deterministic run_instructions text for prepared simulations via GET /api/simulation/\u003cid\u003e, even when X-Locale=en. Thread locale into run-instruction generation, keep command payloads unchanged, add focused regression coverage, and reconcile the parent localization issue notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T17:11:30Z","created_by":"Codex","updated_at":"2026-03-11T17:12:45Z","closed_at":"2026-03-11T17:12:45Z","close_reason":"Localized prepared simulation run instructions via backend i18n, passed focused pytest coverage and scripts/test_backend_lite.sh.","dependencies":[{"issue_id":"mirofish-0of","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T17:11:30Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-ea3","title":"Localize standalone simulation CLI help text","description":"Follow-up from mirofish-1nh. backend/scripts/run_reddit_simulation.py and backend/scripts/run_twitter_simulation.py still expose Chinese-only argparse descriptions/help text and module docs, while the parallel runner already has locale plumbing. Localize only deterministic CLI parser/help strings and matching script banner text via the shared script message path or a small helper, add focused regression coverage for MIROFISH_LOCALE=en, and then reconcile the parent localization task notes.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T16:42:49Z","created_by":"Codex","updated_at":"2026-03-11T16:45:44Z","closed_at":"2026-03-11T16:45:44Z","close_reason":"Made the Reddit/Twitter/parallel simulation entrypoints lazy-load dotenv and simulation-only deps so --help works in a base install, kept English CLI help intact, and added tests/test_simulation_cli_help.py coverage.","dependencies":[{"issue_id":"mirofish-ea3","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T16:42:49Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-cy2","title":"Stabilize profile-format smoke test against localized generator defaults","description":"Follow-up from mirofish-1nh. backend/scripts/test_profile_format.py still instantiates OasisProfileGenerator via __new__ without locale initialization and expects an old Twitter CSV schema, so the smoke test now fails under the current localized profile generator. Harden the generator/script around locale defaults, align the smoke test with the current OASIS CSV/JSON formats, validate it, and then reconcile the parent notes.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T16:36:17Z","created_by":"Codex","updated_at":"2026-03-11T16:36:56Z","closed_at":"2026-03-11T16:36:56Z","close_reason":"Hardened OasisProfileGenerator locale defaulting, updated the profile-format smoke test to current OASIS schemas, and validated with targeted pytest plus scripts/test_backend_lite.sh.","dependencies":[{"issue_id":"mirofish-cy2","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T16:36:16Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-2ei","title":"Localize English report-agent ReACT retry prompts","description":"Follow-up from mirofish-1nh. backend/app/services/report_agent.py still injects Chinese-only deterministic retry/observation/fallback messages into the English ReACT loop when LLM responses are empty, malformed, below the minimum tool-call threshold, or exceed tool-call limits. Localize only those control-plane prompts for locale=en, add focused regression coverage in backend/tests/test_report_agent.py, and reconcile the parent notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T16:27:06Z","created_by":"Codex","updated_at":"2026-03-11T16:29:12Z","closed_at":"2026-03-11T16:29:12Z","close_reason":"Localized English report-agent ReACT control prompts, added focused regression coverage in backend/tests/test_report_agent.py, and validated with targeted pytest plus scripts/test_backend_lite.sh.","dependencies":[{"issue_id":"mirofish-2ei","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T16:27:06Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-hj9","title":"Scope repo-native Russian localization beyond README docs","description":"Upstream PR #147 includes a very large Russian localization branch, but it is not safe to cherry-pick wholesale because it rewrites broad frontend/backend areas and drops current local tooling/tests. Track any future repo-native Russian UI localization as an incremental follow-up separate from the docs-only README-RU subset landed locally.","status":"open","priority":3,"issue_type":"task","owner":"codex@local","created_at":"2026-03-11T16:22:24Z","created_by":"Codex","updated_at":"2026-03-11T16:22:24Z","dependencies":[{"issue_id":"mirofish-hj9","depends_on_id":"mirofish-nmu","type":"discovered-from","created_at":"2026-03-11T16:22:24Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-kwk","title":"Localize report-agent console log messages","description":"Follow-up from mirofish-1nh. backend/app/services/report_agent.py still writes fixed Chinese-only deterministic logger output into console_log.txt (tool execution, outline planning, section generation/completion, fallback paths, report completion/failure, chat scaffolding) even when locale=en. Localize only those fixed logger strings, add focused regression coverage against console_log.txt, and reconcile the parent localization task notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T16:11:39Z","created_by":"Codex","updated_at":"2026-03-11T16:14:18Z","closed_at":"2026-03-11T16:14:18Z","close_reason":"Localized deterministic report-agent console_log.txt messages for English mode, added focused console-log regression coverage, and passed targeted plus lightweight backend validation.","dependencies":[{"issue_id":"mirofish-kwk","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T16:11:38Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-2ax","title":"Localize standalone simulation CLI help text","description":"Follow-up from mirofish-1nh. backend/scripts/run_reddit_simulation.py and backend/scripts/run_twitter_simulation.py still expose Chinese-only argparse descriptions/help text even when MIROFISH_LOCALE=en, while the parallel runner already localizes the same flags. Localize the deterministic CLI parser strings and any matching standalone progress labels via the shared script message catalog, add focused regression coverage, and reconcile the parent issue notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T16:06:53Z","created_by":"Codex","updated_at":"2026-03-11T16:09:00Z","closed_at":"2026-03-11T16:09:00Z","close_reason":"Localized standalone Reddit/Twitter simulation CLI help and deterministic round-progress strings via the shared script message catalog, added coverage in backend/tests/test_llm_env.py, and validated with targeted pytest plus scripts/test_backend_lite.sh.","dependencies":[{"issue_id":"mirofish-2ax","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T16:06:52Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-jqz","title":"Localize Step 5 interview API prompt prefix in zep_tools","description":"Follow-up from mirofish-1nh. backend/app/services/zep_tools.py still prepends a fixed Chinese deterministic prompt-format contract before calling the live Step 5 interview API, even in locale=en. Localize only that direct interview prompt prefix, add focused regression coverage, and reconcile the parent localization issue notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T16:02:44Z","created_by":"Codex","updated_at":"2026-03-11T16:04:25Z","closed_at":"2026-03-11T16:04:25Z","close_reason":"Localized the live Step 5 interview prompt prefix in backend/app/services/zep_tools.py for locale=en, added regression coverage in backend/tests/test_zep_tools_i18n.py, and validated with uv run --project backend pytest -q backend/tests/test_zep_tools_i18n.py plus bash ./scripts/test_backend_lite.sh.","dependencies":[{"issue_id":"mirofish-jqz","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T16:02:43Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-437","title":"Localize Step 5 interview fallback copy in zep_tools","description":"Follow-up from mirofish-1nh. backend/app/services/zep_tools.py still uses fixed Chinese fallback/reasoning strings and Chinese-only LLM prompt scaffolding in the interview selection/question/summary helpers (for example default selection reasoning, fallback questions, empty-interview summary, and prompt labels). Localize only those deterministic Step 5 interview seams via request locale, add focused regression coverage, then reconcile the parent localization task notes.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T15:58:42Z","created_by":"Codex","updated_at":"2026-03-11T16:00:37Z","closed_at":"2026-03-11T16:00:37Z","close_reason":"Localized deterministic Step 5 interview selection/question/summary prompts and fallback copy in backend/app/services/zep_tools.py, added English-mode regression coverage in backend/tests/test_zep_tools_i18n.py, and refreshed upstream coverage/snapshot artifacts.","dependencies":[{"issue_id":"mirofish-437","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T15:58:41Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-azw","title":"Localize history filename fallback","description":"Follow-up from mirofish-1nh. frontend/src/components/HistoryDatabase.vue still falls back to the fixed Chinese label '未知文件' when a history entry has no filename, so English mode can leak Chinese in the file chips/modal. Route that fallback through the history locale catalog, add focused frontend coverage if practical, and reconcile the parent notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T15:42:30Z","created_by":"Codex","updated_at":"2026-03-11T15:43:30Z","closed_at":"2026-03-11T15:43:30Z","close_reason":"Localized the history filename fallback through the shared locale catalog, added a small formatter helper with focused frontend coverage, and validated with npm --prefix frontend test plus npm --prefix frontend run build.","dependencies":[{"issue_id":"mirofish-azw","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T15:42:30Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-158","title":"Localize graph-build worker progress payloads in English mode","description":"Follow-up from mirofish-1nh. backend/app/api/graph.py translated API wrapper task messages but still leaked deterministic GraphBuilderService worker progress strings in Chinese during mid-build polling (for example 开始构建图谱, 图谱已创建, 本体已设置, 文本已分割为 N 个块, 获取图谱信息). Localize those payloads via graph task message translation and add focused regression coverage.","status":"closed","priority":3,"issue_type":"bug","owner":"codex@local","created_at":"2026-03-11T15:34:45Z","created_by":"Codex","updated_at":"2026-03-11T15:34:48Z","closed_at":"2026-03-11T15:34:48Z","close_reason":"Localized deterministic graph-build worker progress messages in backend/app/api/graph.py, added i18n keys for the worker-origin payloads, and covered them with backend/tests/test_graph_upload_api.py plus scripts/test_backend_lite.sh.","dependencies":[{"issue_id":"mirofish-158","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T15:34:45Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-uqh","title":"Localize Step 4 tool-result parsing for English headings","description":"Follow-up from mirofish-1nh and upstream PR #119. frontend/src/components/Step4Report.vue still parses insight_forge and panorama_search tool output with Chinese-only headings like 分析问题 / 预测场景 / 当前有效事实, so English-mode report tool cards can silently lose structured rendering when backend/tool output is localized. Extend those deterministic parsers to accept both Chinese and English headings, add focused frontend regression coverage, and reconcile the parent localization issue notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T15:24:58Z","created_by":"Codex","updated_at":"2026-03-11T15:27:15Z","closed_at":"2026-03-11T15:27:15Z","close_reason":"Moved Step 4 Insight/Panorama parsing into shared bilingual report parsers, added English-heading regression coverage, refreshed upstream coverage metadata, and validated with frontend test/build.","dependencies":[{"issue_id":"mirofish-uqh","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T15:24:57Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-ko7","title":"Localize simulation and graph API exception log contexts","description":"Follow-up from mirofish-1nh. backend/app/api/simulation.py and backend/app/api/graph.py still log deterministic Chinese exception contexts through handle_api_exception even when X-Locale=en. Route those contexts through English-aware helpers, add focused regression coverage, and reconcile the parent localization notes once landed.","status":"closed","priority":3,"issue_type":"bug","owner":"codex@local","created_at":"2026-03-11T15:20:48Z","created_by":"Codex","updated_at":"2026-03-11T15:20:52Z","closed_at":"2026-03-11T15:20:52Z","close_reason":"Localized simulation/graph API exception log contexts, added focused English-mode logger regression tests, and validated with uv run --project backend pytest -q backend/tests/test_simulation_api_i18n.py backend/tests/test_graph_upload_api.py backend/tests/test_error_handler.py plus bash ./scripts/test_backend_lite.sh.","dependencies":[{"issue_id":"mirofish-ko7","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T15:20:48Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-jvq","title":"Localize report API exception contexts","description":"Follow-up from mirofish-1nh. backend/app/api/report.py still passes fixed Chinese-only context strings into handle_api_exception and emits a Chinese-only task-status failure log line, so English-mode requests can still produce deterministic Chinese backend diagnostics. Localize only those fixed report-route error/log messages via backend i18n, add focused regression coverage, and reconcile the parent localization issue notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T15:13:54Z","created_by":"Codex","updated_at":"2026-03-11T15:14:55Z","closed_at":"2026-03-11T15:14:55Z","close_reason":"Localized deterministic report API exception contexts/log labels for English requests, added focused regression coverage in backend/tests/test_report_api_i18n.py, and validated with targeted pytest plus scripts/test_backend_lite.sh.","dependencies":[{"issue_id":"mirofish-jvq","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T15:13:53Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-fle","title":"Localize simulation posts empty-database message","description":"Follow-up from mirofish-1nh. GET /api/simulation/\u003cid\u003e/posts still returns a fixed Chinese-only message when the platform SQLite database does not exist yet (for example before a run or when querying the wrong platform). Localize that deterministic payload via backend i18n, add focused regression coverage, and reconcile the parent localization notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T14:56:59Z","created_by":"Codex","updated_at":"2026-03-11T14:57:42Z","closed_at":"2026-03-11T14:57:42Z","close_reason":"Localized the posts empty-database message via backend i18n, added English-mode regression coverage in backend/tests/test_simulation_api_i18n.py, and validated with uv run --project backend pytest -q backend/tests/test_simulation_api_i18n.py.","dependencies":[{"issue_id":"mirofish-fle","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T14:56:59Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-yqd","title":"Localize MainView workflow/build logs for PR #119","description":"Follow-up from mirofish-1nh and upstream PR #119. frontend/src/views/MainView.vue still emits deterministic English-only and Chinese-hardcoded workflow/build log messages during graph-build setup, project loading, and manual graph refresh. Move those fixed strings behind the locale catalog, add focused frontend regression coverage, and reconcile the parent localization task once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T14:53:43Z","created_by":"Codex","updated_at":"2026-03-11T14:54:22Z","closed_at":"2026-03-11T14:54:22Z","close_reason":"Localized deterministic MainView workflow/build logs behind the locale catalog, added frontend/tests/mainViewLogMessages.test.mjs, and validated with npm --prefix frontend test plus npm --prefix frontend run build.","dependencies":[{"issue_id":"mirofish-yqd","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T14:53:43Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-dxm","title":"Localize deterministic parallel simulation runner output","description":"Follow-up from mirofish-1nh during evolve issue mirofish-rzr. backend/scripts/run_parallel_simulation.py still emits fixed Chinese-only lifecycle and IPC strings (parallel runner banner, wait-mode summary, env startup/shutdown, agent lookup warnings, command-loop errors, and signal-exit output) even when MIROFISH_LOCALE=en. Reuse backend/scripts/llm_env.py message helpers, add focused regression coverage, and reconcile the parent localization issue notes once landed.","status":"closed","priority":3,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T14:47:58Z","created_by":"Codex","updated_at":"2026-03-11T14:49:40Z","closed_at":"2026-03-11T14:49:40Z","close_reason":"Localized deterministic parallel simulation runner output via existing llm_env helpers, added regression assertions, and validated with backend uv pytest plus py_compile and the lightweight backend suite.","dependencies":[{"issue_id":"mirofish-dxm","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T14:47:57Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-7bk","title":"Localize remaining standalone simulation script status messages","description":"Follow-up from mirofish-1nh. backend/scripts/run_parallel_simulation.py, run_reddit_simulation.py, and run_twitter_simulation.py still emit a few deterministic Chinese-only messages in English mode, including optional-dependency installation hints and command-validation/shutdown payload text. Move only fixed strings behind backend/scripts/llm_env.py helpers or explicit locale switches, add focused regression coverage, and reconcile the parent notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T14:36:32Z","created_by":"Codex","updated_at":"2026-03-11T14:38:12Z","closed_at":"2026-03-11T14:38:12Z","close_reason":"Localized remaining standalone runner dependency/status strings, fixed English missing-key errors, and added shared llm_env regression coverage.","dependencies":[{"issue_id":"mirofish-7bk","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T14:36:31Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-0xe","title":"Localize deterministic frontend API error formatting","description":"Follow-up from mirofish-1nh. frontend/src/api/errors.js currently returns backend-provided deterministic config/status messages verbatim, so English mode still shows Chinese strings for known backend validation errors like missing ZEP/LLM config. Localize only recognized deterministic backend messages in the shared frontend formatter, add focused regression coverage, and reconcile the parent localization issue notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T14:33:46Z","created_by":"Codex","updated_at":"2026-03-11T14:34:24Z","closed_at":"2026-03-11T14:34:24Z","close_reason":"Localized deterministic frontend API config-error formatting in frontend/src/api/errors.js, added locale keys, and covered it with frontend/tests/errors.test.mjs plus a production frontend build.","dependencies":[{"issue_id":"mirofish-0xe","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T14:33:45Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-93w","title":"Localize Zep tool result renderers for English report output","description":"Follow-up from mirofish-1nh. backend/app/services/zep_tools.py still renders deterministic Chinese headings/labels in SearchResult, NodeInfo, EdgeInfo, InsightForgeResult, and PanoramaResult to_text() output even when locale=en. Localize only those wrappers, add focused regression coverage, and reconcile the parent issue notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T14:30:39Z","created_by":"Codex","updated_at":"2026-03-11T14:31:57Z","closed_at":"2026-03-11T14:31:57Z","close_reason":"Localized deterministic SearchResult/NodeInfo/EdgeInfo/InsightForgeResult/PanoramaResult wrappers for locale=en in zep_tools and added focused regression coverage in backend/tests/test_zep_tools_i18n.py.","dependencies":[{"issue_id":"mirofish-93w","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T14:30:38Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-5ez","title":"Localize backend app startup and request logs","description":"Follow-up from mirofish-1nh. backend/app/__init__.py still emits deterministic Chinese-only startup and request/response log lines (backend starting, cleanup registration, request line/body, response status, startup complete) even when MIROFISH_LOCALE=en or the request header asks for English. Localize only those fixed log messages via backend i18n, add focused regression coverage, and reconcile the parent localization issue notes when landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T14:23:07Z","created_by":"Codex","updated_at":"2026-03-11T14:24:06Z","closed_at":"2026-03-11T14:24:06Z","close_reason":"Localized backend app startup/request logs via backend i18n and added focused regression coverage.","dependencies":[{"issue_id":"mirofish-5ez","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T14:23:06Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-76e","title":"Localize backend startup validation output","description":"Follow-up from mirofish-1nh. backend/run.py still prints fixed Chinese-only startup validation text (header plus .env guidance) when config validation fails outside a request context. Resolve locale via explicit arg or MIROFISH_LOCALE, route the deterministic startup strings through backend i18n, add focused regression coverage, and reconcile the parent issue notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T14:19:45Z","created_by":"Codex","updated_at":"2026-03-11T14:20:12Z","closed_at":"2026-03-11T14:20:12Z","close_reason":"Localized backend startup validation output in backend/run.py via backend i18n, honoring MIROFISH_LOCALE for non-request startup failures, and added backend/tests/test_run.py regression coverage.","dependencies":[{"issue_id":"mirofish-76e","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T14:19:44Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-vxd","title":"Localize persisted report-agent task log messages","description":"Follow-up from mirofish-1nh. backend/app/services/report_agent.py writes deterministic progress/error messages into agent_log.jsonl in Chinese only (report start, planning start/complete, section start, tool call/result, section complete, report complete, error) even when locale=en. Thread locale into the report logger, localize only those fixed strings, add focused regression coverage, and update the parent issue notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T13:56:57Z","created_by":"Codex","updated_at":"2026-03-11T13:58:21Z","closed_at":"2026-03-11T13:58:21Z","close_reason":"Localized deterministic report-agent task messages in agent_log.jsonl for locale=en by threading locale through ReportLogger, added backend i18n keys, and covered it with backend/tests/test_report_agent.py plus uv run --project backend pytest -q backend/tests/test_report_agent.py backend/tests/test_report_api_i18n.py and bash ./scripts/test_backend_lite.sh.","dependencies":[{"issue_id":"mirofish-vxd","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T13:56:57Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-r6q","title":"Localize SimulationRunner cleanup and shutdown messages","description":"Follow-up from mirofish-1nh. SimulationRunner still emits fixed Chinese-only deterministic strings during forced cleanup and server shutdown, including cleanup_simulation_logs return/errors and the persisted run_state error written when the backend terminates active simulations. Localize only those deterministic messages via backend i18n, add focused regression coverage, and update the parent notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T13:45:45Z","created_by":"Codex","updated_at":"2026-03-11T13:47:10Z","closed_at":"2026-03-11T13:47:10Z","close_reason":"Localized SimulationRunner cleanup/shutdown messages via backend i18n, added focused regression coverage, and validated with backend/tests/test_simulation_runner_actions.py plus scripts/test_backend_lite.sh.","dependencies":[{"issue_id":"mirofish-r6q","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T13:45:45Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-bwe","title":"Localize deterministic ZepTools interview text for English mode","description":"Follow-up from mirofish-1nh. backend/app/services/zep_tools.py still emits fixed Chinese-only interview placeholders/summaries and report text wrappers in English mode (for example missing-profile summary, interview API failure summary, no-reply placeholders, dual-platform answer labels, and InterviewResult.to_text headings). Localize only deterministic strings via locale-aware helpers, add focused regression coverage, and reconcile the parent notes once landed.","status":"closed","priority":3,"issue_type":"bug","owner":"codex@local","created_at":"2026-03-11T13:41:17Z","created_by":"Codex","updated_at":"2026-03-11T13:42:49Z","closed_at":"2026-03-11T13:42:49Z","close_reason":"Localized deterministic ZepTools interview placeholders, summaries, and report text wrappers for English mode; added backend/tests/test_zep_tools_i18n.py regression coverage and validated with targeted backend pytest.","dependencies":[{"issue_id":"mirofish-bwe","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T13:41:16Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-as6","title":"Design one-click workflow run beyond already-landed upfront rounds","description":"Track the remaining feature part of upstream issue #62. Upfront simulation-round configuration is already landed locally in Step 2, along with timeout-budget guidance for Step 5. The remaining gap is optional one-click orchestration across Step 1-5 with fewer intermediate confirmations, which still needs deliberate UX/orchestration design around validation, long-running task sequencing, failure recovery, and status reporting instead of a blind shortcut button.","notes":"2026-03-11: Narrowed scope after refreshing upstream intake. Upfront round configuration is no longer part of the remaining gap; keep this bead focused on the one-click orchestration request only.","status":"open","priority":3,"issue_type":"feature","owner":"codex@local","created_at":"2026-03-11T13:37:39Z","created_by":"Codex","updated_at":"2026-03-11T17:06:21Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-77h","title":"Design multi-run simulation consensus workflow","description":"Track upstream issue #17. Users want to run Step 3 multiple times against the same Step 1/2 setup and have Step 4 produce a consensus/divergence meta-report. This is not a low-risk one-pass fix, but it is a concrete feature request worth preserving in beads-backed tracking. Scope later work around shared graph/env reuse, simulation batch orchestration, and report synthesis across runs.","status":"open","priority":3,"issue_type":"feature","owner":"codex@local","created_at":"2026-03-11T13:36:14Z","created_by":"Codex","updated_at":"2026-03-11T13:36:14Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-62j","title":"Localize service-layer simulation errors and utility exceptions","description":"Follow-up from mirofish-1nh. Several backend service/utility seams still raise fixed Chinese-only deterministic errors even in English mode, including FileParser PDF dependency failures, SimulationIPC timeout errors, SimulationManager missing/empty-entity messages, and SimulationRunner stop/interview config/state errors. Route those through backend i18n, add focused regression coverage, and reconcile the parent issue notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T13:31:10Z","created_by":"Codex","updated_at":"2026-03-11T13:33:48Z","closed_at":"2026-03-11T13:33:48Z","close_reason":"Localized deterministic simulation service errors and utility exceptions via backend i18n, added backend/tests/test_simulation_service_i18n.py, and validated with uv run --project backend pytest -q backend/tests/test_simulation_service_i18n.py backend/tests/test_backend_localized_errors.py backend/tests/test_simulation_api_i18n.py plus bash ./scripts/test_backend_lite.sh.","dependencies":[{"issue_id":"mirofish-62j","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T13:31:09Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-dqd","title":"Localize ontology generator prompts for English mode","description":"Follow-up from mirofish-1nh. backend/app/services/ontology_generator.py still sends largely Chinese system/user prompt content and truncation notices even when locale=en, which biases deterministic ontology guidance and analysis_summary generation back toward Chinese. Localize only the fixed prompt/template text for English mode, add focused regression coverage, and reconcile the parent notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T13:03:05Z","created_by":"Codex","updated_at":"2026-03-11T13:04:20Z","closed_at":"2026-03-11T13:04:20Z","close_reason":"Localized ontology generator English prompts/templates, added focused regression coverage, and fixed the ontology test helper isolation issue uncovered by combined pytest runs.","dependencies":[{"issue_id":"mirofish-dqd","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T13:03:05Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-fh2","title":"Localize standalone runner lifecycle logs","description":"Follow-up from mirofish-1nh / upstream PR #119. backend/scripts/run_reddit_simulation.py and run_twitter_simulation.py still emit deterministic Chinese-only lifecycle output (banner text, truncation/parameter summaries, env setup/teardown, initial post warnings, and command-loop shutdown messages) even when MIROFISH_LOCALE=en. Move only fixed strings behind backend/scripts/llm_env.py helpers, add focused regression coverage, and reconcile the parent issue notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T12:55:46Z","created_by":"Codex","updated_at":"2026-03-11T12:58:01Z","closed_at":"2026-03-11T12:58:01Z","close_reason":"Localized standalone Reddit/Twitter runner lifecycle logs behind shared llm_env messages and validated with targeted pytest + py_compile.","dependencies":[{"issue_id":"mirofish-fh2","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T12:55:45Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-fuj","title":"Localize Zep graph-memory activity text for English simulations","description":"Follow-up from mirofish-1nh and upstream PR #119. The optional Zep graph-memory updater still converts deterministic simulation actions into Chinese-only episode text and platform labels even when a simulation is started with locale=en. Thread the simulation locale into the updater, localize the deterministic action descriptions/log labels only, add focused regression coverage, and reconcile the parent notes when landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T12:42:26Z","created_by":"Codex","updated_at":"2026-03-11T12:44:54Z","closed_at":"2026-03-11T12:44:54Z","close_reason":"Localized Zep graph-memory activity text for English simulations, added regression coverage, and validated the affected runtime/i18n seams.","dependencies":[{"issue_id":"mirofish-fuj","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T12:42:26Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-6xt","title":"Localize deterministic simulation runner script messages","description":"Follow-up from mirofish-1nh. The standalone simulation runner scripts still emit fixed Chinese-only startup/interview/profile/config strings (for example init/model/profile loading, profile/config missing, and interview completion/failure summaries) even when MIROFISH_LOCALE=en. Move these deterministic messages behind a shared helper, add focused regression tests, and update the parent notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T12:30:20Z","created_by":"Codex","updated_at":"2026-03-11T12:32:41Z","closed_at":"2026-03-11T12:32:41Z","close_reason":"Localized deterministic simulation runner script messages via a shared helper, added runtime-string regression coverage in backend/tests/test_llm_env.py, and restored the simulation-runner shim so the focused backend validation passes again.","dependencies":[{"issue_id":"mirofish-6xt","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T12:30:20Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-ph6","title":"Localize deterministic backend service/config errors","description":"Follow-up from mirofish-1nh. Several backend utility/service seams still emit fixed Chinese-only deterministic errors even in English mode, including missing LLM key messages in llm_client/profile/config generators, file-parser unsupported-format failures, and generic task status messages. Localize only deterministic strings via backend i18n, add targeted regression coverage, and update the parent issue notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T12:26:28Z","created_by":"Codex","updated_at":"2026-03-11T12:28:32Z","closed_at":"2026-03-11T12:28:32Z","close_reason":"Localized deterministic backend service/config errors, added regression coverage, and validated the affected backend/i18n seams.","dependencies":[{"issue_id":"mirofish-ph6","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T12:26:27Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-7r7","title":"Localize graph build task status payloads","description":"Follow-up from mirofish-1nh and upstream issue #117. The graph build API still writes fixed Chinese-only task status/progress messages into TaskManager (startup message, build stages, completion, and failure prefixes), so English mode gets untranslated graph-build progress even though simulation/report task payloads are localized. Localize only deterministic graph-build task messages via backend i18n, add targeted regression coverage, and reconcile the parent issue notes if landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T11:48:18Z","created_by":"Codex","updated_at":"2026-03-11T11:49:33Z","closed_at":"2026-03-11T11:49:33Z","close_reason":"Localized deterministic graph build task status/progress messages for English mode, added targeted backend regression coverage, and passed focused plus lightweight backend validation.","dependencies":[{"issue_id":"mirofish-7r7","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T11:48:18Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-f19","title":"Localize hardcoded Step 1/2 workflow labels","description":"Follow-up from mirofish-1nh. Step1GraphBuild.vue and Step2EnvSetup.vue still contain hardcoded non-catalog labels/badges (for example ontology detail section headers, generated tag headers, info-card labels, and profile fallback copy) that bypass the existing EN/ZH locale toggle. Move only deterministic UI copy into the locale catalogs, wire the components to use i18n keys, and validate with frontend tests/build.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T11:45:39Z","created_by":"Codex","updated_at":"2026-03-11T11:46:19Z","closed_at":"2026-03-11T11:46:19Z","close_reason":"Localized the remaining hardcoded Step 1/2 workflow labels behind the existing EN/ZH locale catalogs and validated with frontend tests/build.","dependencies":[{"issue_id":"mirofish-f19","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T11:45:39Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-4hh","title":"Localize async report and prepare task progress payloads","description":"Follow-up from mirofish-1nh. Async report generation and simulation prepare flows still leak fixed Chinese-only task progress/error strings (for example report init/progress labels, simulation prepare stage names, and direct simulation-not-found responses) even when X-Locale=en. Localize only deterministic task payload strings via backend i18n, add targeted regression coverage, and update the parent issue notes if landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T11:27:47Z","created_by":"Codex","updated_at":"2026-03-11T11:30:26Z","closed_at":"2026-03-11T11:30:26Z","close_reason":"Localized deterministic async report/prepare progress payloads and added regression coverage.","dependencies":[{"issue_id":"mirofish-4hh","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T11:27:46Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-v1m","title":"Localize remaining simulation API status payloads","description":"Follow-up from mirofish-1nh. Several simulation API endpoints still return fixed Chinese-only status or validation payloads in create/prepare/prepare-status/profile generation/interview env-status/close-env paths (for example prepare-started, already-prepared, not-started, missing graph_id/interviews, and env close messages). Localize only deterministic response strings via backend i18n, add targeted regression coverage, and keep model-generated content out of scope.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T11:02:36Z","created_by":"Codex","updated_at":"2026-03-11T11:06:54Z","closed_at":"2026-03-11T11:06:54Z","close_reason":"Localized the remaining deterministic simulation API status payloads, added targeted English-mode regression tests, and folded them into the lightweight backend suite.","dependencies":[{"issue_id":"mirofish-v1m","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T11:02:36Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-kr0","title":"Bilingualize .env.example for direct OpenAI-compatible setup","description":"Follow-up from upstream issue #117 and objective 7. The repo already supports direct OpenAI/Codex-compatible backends via OPENAI_* aliases, but the shipped .env.example remains Chinese-only. Make the env template bilingual, keep the existing examples aligned with README.md/README-EN.md, and refresh upstream triage notes after the latest snapshot churn.","status":"closed","priority":3,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T10:57:10Z","created_by":"Codex","updated_at":"2026-03-11T10:57:51Z","closed_at":"2026-03-11T10:57:51Z","close_reason":"Made .env.example bilingual for direct OpenAI-compatible setup, refreshed upstream open/full snapshots to 34 open issues and 82 total issues, and recorded the latest intake state in triage notes.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-95p","title":"Localize remaining deterministic ZEP backend error strings","description":"Follow-up from mirofish-1nh and upstream issue #117. Several backend API/service paths still return fixed Chinese-only ZEP/config messages (for example ZEP_API_KEY missing and graph/simulation service constructor errors). Localize only deterministic strings via backend i18n, add targeted regression coverage, and update the parent issue notes once landed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T10:42:17Z","created_by":"Codex","updated_at":"2026-03-11T10:44:31Z","closed_at":"2026-03-11T10:44:31Z","close_reason":"Localized deterministic ZEP-backed backend error strings and added regression coverage.","dependencies":[{"issue_id":"mirofish-95p","depends_on_id":"mirofish-1nh","type":"discovered-from","created_at":"2026-03-11T10:42:16Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-uxp","title":"Refresh upstream triage notes after March 11 snapshot","description":"The open/all upstream snapshot artifacts have been refreshed again on March 11, 2026 and now show 36 open issues, 33 open PRs, and 83 total issues in the full view. Update docs/upstream-triage.md so the human summary matches the current machine-readable snapshot state, then close the issue.","status":"closed","priority":3,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T10:02:32Z","created_by":"Codex","updated_at":"2026-03-11T10:02:52Z","closed_at":"2026-03-11T10:02:52Z","close_reason":"Updated upstream triage notes to match the March 11 refreshed snapshots.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-3j8","title":"Design repo-native Windows packaging workflow","description":"Follow-up from upstream PR #108 review. The mirrored branch is not safe to cherry-pick because its launcher/build flow assumes a backend app.py entrypoint, starts the frontend dev server instead of serving the built frontend, and bundles already-landed Docker workflow changes. Replace it with a repo-native packaging design that targets run.py or the actual production backend entrypoint, uses built frontend assets, and separates packaging work from unrelated CI diffs.","status":"open","priority":3,"issue_type":"task","owner":"codex@local","created_at":"2026-03-11T09:39:03Z","created_by":"Codex","updated_at":"2026-03-11T09:39:03Z","dependencies":[{"issue_id":"mirofish-3j8","depends_on_id":"mirofish-auf","type":"discovered-from","created_at":"2026-03-11T09:39:02Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-1nh","title":"Localize backend/runtime-generated content beyond frontend chrome","description":"Follow-up to mirofish-0nq. The shared graph panel and Step 5 deep-interaction workspace are now localized, but English mode still depends on backend/model-generated payloads that often arrive in Chinese (for example agent bios/professions, backend error strings, and report/runtime content). Continue only with explicit, low-risk localization seams instead of hardcoding translations for generated content.","notes":"2026-03-11: Localized two remaining deterministic zep_tools English fallback labels in backend/app/services/zep_tools.py so Panorama historical fact ranges now use Unknown for missing timestamps and InsightForge default entity types render Entity instead of 实体. Covered in backend/tests/test_zep_tools_i18n.py and validated with targeted pytest plus compileall.\n2026-03-11: Localized simulation_config_generator unknown fallback labels so zh mode no longer leaks English 'Unknown' in entity summaries, generated agent-config metadata, or initial-post poster defaults. Covered in backend/tests/test_openai_compat_services.py with focused locale regression coverage.\n2026-03-11: Localized OasisProfileGenerator rule-based fallback bios/personas/profession labels/interested-topic labels so deterministic no-LLM fallback profiles no longer leak English-only copy in zh mode. Covered in backend/tests/test_openai_compat_services.py and validated with targeted pytest plus compileall.\n2026-03-11: Tightened SimulationConfigGenerator English prompt instructions so time-config reasoning plus event narrative_direction, initial post content, and reasoning explicitly require English output. Covered in backend/tests/test_openai_compat_services.py and validated with targeted pytest plus compileall.\n2026-03-11: Localized ReportAgent section-generation system/user prompt scaffolding so locale=en no longer starts Step 4 section drafting from Chinese instructions/examples/tool-usage guidance. Covered in backend/tests/test_report_agent.py and validated with focused pytest, compileall, and scripts/test_backend_lite.sh.\n2026-03-11: Localized the Report Agent chat system prompt so locale=en Step 4 assistant chat no longer starts from Chinese-only instructions, tool-call schema labels, or style guidance. Covered in backend/tests/test_report_agent.py and validated with focused pytest, compileall, and scripts/test_backend_lite.sh.","status":"closed","priority":3,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T08:02:57Z","created_by":"Codex","updated_at":"2026-03-11T22:53:06Z","closed_at":"2026-03-11T22:53:06Z","close_reason":"Closed the remaining deterministic localization seams worth landing safely; deeper localization gaps are now tracked separately as model-generated content enforcement work.","dependencies":[{"issue_id":"mirofish-1nh","depends_on_id":"mirofish-0nq","type":"discovered-from","created_at":"2026-03-11T08:02:56Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-auf","title":"Review upstream PR #108 Windows installer packaging","description":"Evaluate 666ghj/MiroFish#108 now that its head is mirrored locally. The PR is mergeable but adds installer/build.ps1, installer docs, and release-packaging assumptions for Windows, so it should be reviewed as a standalone distribution feature rather than cherry-picked blindly.","status":"closed","priority":3,"issue_type":"task","owner":"codex@local","created_at":"2026-03-11T07:43:56Z","created_by":"Codex","updated_at":"2026-03-11T09:39:03Z","closed_at":"2026-03-11T09:39:03Z","close_reason":"Reviewed upstream PR #108 and determined it is not safe to cherry-pick: the packaging script targets the wrong runtime shape for this repo and bundles already-landed CI changes. Split the remaining work into a concrete repo-native packaging follow-up issue.","dependencies":[{"issue_id":"mirofish-auf","depends_on_id":"mirofish-0l1","type":"discovered-from","created_at":"2026-03-11T07:43:56Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-0nq","title":"Broaden English localization beyond shell and history views","description":"Follow-up from mirofish-57e / upstream PR #119. The current branch now supports a persisted EN/ZH toggle for the home page, workflow header, history modal, graph-build process view, Step 3 simulation monitor, and Step 4 report-generation shell/report view chrome. Remaining gaps are deeper Step 4/5 content rendering, tool-result parsing copy, and backend-generated messages that are still Chinese-first.","status":"closed","priority":3,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T07:26:48Z","created_by":"Codex","updated_at":"2026-03-11T08:02:57Z","closed_at":"2026-03-11T08:02:57Z","close_reason":"Localized the shared graph panel plus Step 5 deep-interaction chrome, moved the remaining backend/runtime-generated localization gaps into a narrower follow-up.","dependencies":[{"issue_id":"mirofish-0nq","depends_on_id":"mirofish-57e","type":"discovered-from","created_at":"2026-03-11T07:26:47Z","created_by":"Codex","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"mirofish-2uk","title":"Evaluate repo-native pre-commit/hooks workflow for upstream issue #146","description":"Upstream issue #146 requests Husky-based automated git-hook checks. Preserve the request locally, but scope it as a repo-native workflow evaluation rather than assuming Husky is the right fit for this mixed Python/Vue/beads repo. Compare Husky against lighter alternatives (for example repo scripts or pre-commit), account for backend/frontend validation cost, and avoid introducing mandatory Node-only commit hooks without an explicit contributor workflow decision.","status":"closed","priority":4,"issue_type":"task","assignee":"Codex","owner":"codex@local","created_at":"2026-03-11T15:15:45Z","created_by":"Codex","updated_at":"2026-03-11T15:40:04Z","closed_at":"2026-03-11T15:40:04Z","close_reason":"Implemented an opt-in repo-native git hook workflow with shared validation scripts, contributor docs, and refreshed upstream coverage/mirror metadata for issue #146.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/.env.example b/.env.example index 78a3b72c..3d687201 100644 --- a/.env.example +++ b/.env.example @@ -1,16 +1,73 @@ -# LLM API配置(支持 OpenAI SDK 格式的任意 LLM API) -# 推荐使用阿里百炼平台qwen-plus模型:https://bailian.console.aliyun.com/ -# 注意消耗较大,可先进行小于40轮的模拟尝试 +# LLM API config / LLM API 配置 +# Supports any OpenAI-SDK-compatible backend / 支持任意 OpenAI SDK 兼容后端 +# Also accepts standard OPENAI_API_KEY / OPENAI_BASE_URL / OPENAI_MODEL aliases +# 也兼容标准 OPENAI_API_KEY / OPENAI_BASE_URL / OPENAI_MODEL 环境变量 +# OPENAI_API_BASE_URL is also recognized automatically / 如果外部工具导出的是 OPENAI_API_BASE_URL,后端也会自动识别 +# Recommended default: Alibaba Bailian qwen-plus / 推荐默认使用阿里百炼 qwen-plus +# https://bailian.console.aliyun.com/ +# Simulations can be expensive; start with fewer than 40 rounds / 注意消耗较大,可先进行小于 40 轮的模拟尝试 +# If you already have an OpenAI / Codex-compatible gateway, you can use only OPENAI_* vars +# 如果你已经有 OpenAI / Codex 兼容网关,也可以只设置 OPENAI_* 变量 +# No extra LLM_PROVIDER flag is required / 不需要额外的 LLM_PROVIDER 开关 +# FLASK_DEBUG defaults to false and SECRET_KEY falls back to a temporary random value +# FLASK_DEBUG 默认关闭,未设置 SECRET_KEY 时会退回到临时随机值 +# Docker deployment can override the pulled image without editing docker-compose.yml +# Docker 部署时可通过该变量覆盖镜像地址,无需手改 docker-compose.yml +# MIROFISH_IMAGE=ghcr.io/666ghj/mirofish:latest +# SECRET_KEY=replace_with_a_long_random_secret +# FLASK_DEBUG=false LLM_API_KEY=your_api_key_here LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 LLM_MODEL_NAME=qwen-plus +# Reduce this for smaller-context models to avoid report-agent token overflow +# 上下文窗口较小的模型可以降低单次输出上限,避免 report agent 触发 token overflow +# LLM_MAX_TOKENS=4096 -# ===== ZEP记忆图谱配置 ===== -# 每月免费额度即可支撑简单使用:https://app.getzep.com/ +# Other common OpenAI-compatible examples (pick one style; do not mix with the block above) +# 其他常见 OpenAI-compatible 写法示例(二选一参考即可,不要和上面的示例混用) +# OpenAI / Codex-compatible gateway / OpenAI / Codex-compatible 网关: +# OPENAI_API_KEY=your_api_key_here +# OPENAI_BASE_URL=https://api.openai.com/v1 +# OPENAI_API_BASE_URL=https://api.openai.com/v1 +# OPENAI_MODEL=gpt-4.1-mini +# +# Alibaba DashScope Coding Plan / 阿里云百炼 Coding Plan: +# OPENAI_API_KEY=your_dashscope_key_here +# OPENAI_API_BASE_URL=https://coding.dashscope.aliyuncs.com/v1 +# OPENAI_MODEL=qwen3.5-plus + +# ===== Zep graph memory config / Zep 记忆图谱配置 ===== +# Free monthly quota is enough for simple usage / 每月免费额度即可支撑简单使用 +# https://app.getzep.com/ ZEP_API_KEY=your_zep_api_key_here -# ===== 加速 LLM 配置(可选)===== -# 注意如果不使用加速配置,env文件中就不要出现下面的配置项 +# ===== Boost LLM config (optional) / 加速 LLM 配置(可选)===== +# Omit these entirely if you do not use boost mode / 注意如果不使用加速配置,env 文件中就不要出现下面的配置项 LLM_BOOST_API_KEY=your_api_key_here LLM_BOOST_BASE_URL=your_base_url_here -LLM_BOOST_MODEL_NAME=your_model_name_here \ No newline at end of file +LLM_BOOST_MODEL_NAME=your_model_name_here + +# ===== Frontend config (optional) / 前端配置(可选)===== +# Dev-mode proxy target for the frontend; default is http://localhost:5001 +# 开发模式前端代理到的后端地址,默认 http://localhost:5001 +# Set VITE_API_BASE_URL if the backend runs on another host or port +# 如果后端运行在其他主机或端口,请设置 VITE_API_BASE_URL +# VITE_API_BASE_URL=http://localhost:5001 + +# Backend CORS now defaults to localhost/127.0.0.1 dev origins on ports 3000/4173/5173 +# 后端 CORS 默认仅放行 localhost/127.0.0.1 的 3000/4173/5173 开发端口 +# If frontend/backend use different deployed origins, set these explicitly +# 如果前后端部署在不同正式域名上,请显式设置下面这些变量 +# CORS_ALLOWED_ORIGINS=http://localhost:3000,https://mirofish.example.com +# CORS_ALLOW_METHODS=GET,POST,PUT,PATCH,DELETE,OPTIONS +# CORS_ALLOW_HEADERS=Content-Type,Authorization,X-Locale + +# Increase this for slower local models (milliseconds) / 本地大模型响应较慢时可以增加此值(毫秒) +# VITE_API_TIMEOUT=600000 + +# Step 5 interaction timeouts in seconds (optional) / Step 5 深度互动等待时间(秒,可选) +# Increase these when local models or large interviews hit Interview / IPC timeouts +# 本地模型或大规模访谈容易触发 Interview / IPC 超时,可按需调大 +# INTERVIEW_AGENT_TIMEOUT_SECONDS=120 +# INTERVIEW_BATCH_TIMEOUT_SECONDS=240 +# INTERVIEW_ALL_TIMEOUT_SECONDS=300 diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 00000000..14db61f6 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(git rev-parse --show-toplevel)" +cd "$ROOT_DIR" + +echo "[mirofish] pre-commit: backend lite tests + frontend tests" +bash ./scripts/validate_repo.sh --skip-frontend-build diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 00000000..b8d94daf --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(git rev-parse --show-toplevel)" +cd "$ROOT_DIR" + +echo "[mirofish] pre-push: full validation" +bash ./scripts/validate_repo.sh diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index becbdec5..49759172 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -39,11 +39,14 @@ jobs: type=sha type=raw,value=latest - - name: Build and push - uses: docker/build-push-action@v5 + - name: Build and push (multi-platform) + uses: docker/build-push-action@v6 with: context: . file: ./Dockerfile push: true + platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index 55d3ef19..b9c36cdb 100644 --- a/.gitignore +++ b/.gitignore @@ -57,4 +57,11 @@ backend/logs/ backend/uploads/ # Docker 数据 -data/ \ No newline at end of file +data/ + +# AgentOps session artifacts (auto-added by ao init) +.agents/ + +# Dolt database files (added by bd init) +.dolt/ +*.db diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..c951c075 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,150 @@ +# Agent Instructions + +This project uses **bd** (beads) for issue tracking. Run `bd onboard` to get started. + +## Quick Reference + +```bash +bd ready # Find available work +bd show # View issue details +bd update --claim # Claim work atomically +bd close # Complete work +bd sync # Sync with git +``` + +## Non-Interactive Shell Commands + +**ALWAYS use non-interactive flags** with file operations to avoid hanging on confirmation prompts. + +Shell commands like `cp`, `mv`, and `rm` may be aliased to include `-i` (interactive) mode on some systems, causing the agent to hang indefinitely waiting for y/n input. + +**Use these forms instead:** +```bash +# Force overwrite without prompting +cp -f source dest # NOT: cp source dest +mv -f source dest # NOT: mv source dest +rm -f file # NOT: rm file + +# For recursive operations +rm -rf directory # NOT: rm -r directory +cp -rf source dest # NOT: cp -r source dest +``` + +**Other commands that may prompt:** +- `scp` - use `-o BatchMode=yes` for non-interactive +- `ssh` - use `-o BatchMode=yes` to fail instead of prompting +- `apt-get` - use `-y` flag +- `brew` - use `HOMEBREW_NO_AUTO_UPDATE=1` env var + + +## Issue Tracking with bd (beads) + +**IMPORTANT**: This project uses **bd (beads)** for ALL issue tracking. Do NOT use markdown TODOs, task lists, or other tracking methods. + +### Why bd? + +- Dependency-aware: Track blockers and relationships between issues +- Version-controlled: Built on Dolt with cell-level merge +- Agent-optimized: JSON output, ready work detection, discovered-from links +- Prevents duplicate tracking systems and confusion + +### Quick Start + +**Check for ready work:** + +```bash +bd ready --json +``` + +**Create new issues:** + +```bash +bd create "Issue title" --description="Detailed context" -t bug|feature|task -p 0-4 --json +bd create "Issue title" --description="What this issue is about" -p 1 --deps discovered-from:bd-123 --json +``` + +**Claim and update:** + +```bash +bd update --claim --json +bd update bd-42 --priority 1 --json +``` + +**Complete work:** + +```bash +bd close bd-42 --reason "Completed" --json +``` + +### Issue Types + +- `bug` - Something broken +- `feature` - New functionality +- `task` - Work item (tests, docs, refactoring) +- `epic` - Large feature with subtasks +- `chore` - Maintenance (dependencies, tooling) + +### Priorities + +- `0` - Critical (security, data loss, broken builds) +- `1` - High (major features, important bugs) +- `2` - Medium (default, nice-to-have) +- `3` - Low (polish, optimization) +- `4` - Backlog (future ideas) + +### Workflow for AI Agents + +1. **Check ready work**: `bd ready` shows unblocked issues +2. **Claim your task atomically**: `bd update --claim` +3. **Work on it**: Implement, test, document +4. **Discover new work?** Create linked issue: + - `bd create "Found bug" --description="Details about what was found" -p 1 --deps discovered-from:` +5. **Complete**: `bd close --reason "Done"` + +### Auto-Sync + +bd automatically syncs with git: + +- Exports to `.beads/issues.jsonl` after changes (5s debounce) +- Imports from JSONL when newer (e.g., after `git pull`) +- No manual export/import needed! + +### Important Rules + +- ✅ Use bd for ALL task tracking +- ✅ Always use `--json` flag for programmatic use +- ✅ Link discovered work with `discovered-from` dependencies +- ✅ Check `bd ready` before asking "what should I work on?" +- ❌ Do NOT create markdown TODO lists +- ❌ Do NOT use external issue trackers +- ❌ Do NOT duplicate tracking systems + +For more details, see README.md and docs/QUICKSTART.md. + +## Landing the Plane (Session Completion) + +**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds. + +**MANDATORY WORKFLOW:** + +1. **File issues for remaining work** - Create issues for anything that needs follow-up +2. **Run quality gates** (if code changed) - Tests, linters, builds +3. **Update issue status** - Close finished work, update in-progress items +4. **PUSH TO REMOTE** - This is MANDATORY: + ```bash + git pull --rebase + bd sync + git push + git status # MUST show "up to date with origin" + ``` +5. **Clean up** - Clear stashes, prune remote branches +6. **Verify** - All changes committed AND pushed +7. **Hand off** - Provide context for next session + +**CRITICAL RULES:** +- Work is NOT complete until `git push` succeeds +- NEVER stop before pushing - that leaves work stranded locally +- NEVER say "ready to push when you are" - YOU must push +- If push fails, resolve and retry until it succeeds + + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..4893bd4c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,92 @@ +# 贡献指南 + +感谢你对 MiroFish 项目的兴趣!我们欢迎任何形式的贡献。 + +## 如何贡献 + +### 1. Fork 仓库 +点击 GitHub 仓库页面的 "Fork" 按钮,将仓库复制到你的账户。 + +### 2. 克隆你的 Fork +```bash +git clone https://github.com/你的用户名/MiroFish.git +cd MiroFish +``` + +### 3. 创建分支 +```bash +git checkout -b feature/你的功能名称 +# 或 +git checkout -b fix/问题描述 +``` + +### 4. 进行修改 +- 确保代码符合项目规范 +- 添加必要的测试 +- 更新相关文档 + +### 4.1 运行校验 +提交前建议至少运行一次仓库级校验: + +```bash +npm run validate +``` + +如果你只想先跑较快的一轮检查,可以使用: + +```bash +npm run validate:fast +``` + +`validate:fast` 会运行轻量级后端测试集和前端测试;`validate` 会额外执行前端生产构建。 + +### 4.2 安装可选 Git Hooks +这个仓库提供了轻量、仓库内置的 Git hooks,不依赖 Husky,也不强制所有贡献者启用: + +```bash +npm run hooks:install +``` + +安装后: + +- `pre-commit` 会运行后端轻量测试和前端测试 +- `pre-push` 会运行完整校验(含前端 build) + +如需取消,只需执行: + +```bash +git config --unset core.hooksPath +``` + +### 5. 提交更改 +```bash +git add . +git commit -m "描述你的修改" +``` + +### 6. 推送到你的 Fork +```bash +git push -u origin docs/add-pr-guide +``` + +### 7. 创建 Pull Request +1. 访问你的 GitHub Fork 仓库 +2. 点击 "Compare & pull request" 按钮 +3. 填写 PR 描述 +4. 点击 "Create pull request" + +## 分支命名规范 + +- `feature/` - 新功能 +- `fix/` - Bug 修复 +- `docs/` - 文档更新 +- `refactor/` - 代码重构 + +## 代码规范 + +- 前端: 遵循 Vue.js 风格指南 +- 后端: 遵循 PEP 8 Python 代码规范 + +## 许可证 + +参与本项目即表示你同意遵守 MIT 许可证。 diff --git a/GOALS.md b/GOALS.md new file mode 100644 index 00000000..732fd8e6 --- /dev/null +++ b/GOALS.md @@ -0,0 +1,40 @@ +# Goals + +Keep MiroFish shippable while continuously triaging upstream changes and improving OpenAI-compatible backend interoperability. + +## North Stars + +- Safe upstream fixes land quickly without destabilizing the fork. +- OpenAI-compatible and local LLM backends work without code edits outside configuration. + +## Anti Stars + +- Broad merges that mix unrelated upstream changes into one risky batch. +- Backend compatibility claims that are undocumented or unsupported by tests. + +## Directives + +### 1. Maintain Upstream Triage + +Continuously ingest the upstream issue and pull-request backlog into beads-backed work so the fork has a current, execution-ready queue. + +**Steer:** increase + +### 2. Prefer Safe, Incremental Upstream Adoption + +Cherry-pick or supersede the smallest safe upstream fixes first, especially around configuration, parsing, and compatibility behavior. + +**Steer:** increase + +### 3. Keep Backend Compatibility Explicit + +Support both project-specific `LLM_*` settings and standard OpenAI-compatible environment variables, then document the supported setup paths. + +**Steer:** increase + +## Gates + +| ID | Check | Weight | Description | +|----|-------|--------|-------------| +| frontend-build | `npm run build` | 4 | Frontend production build succeeds | +| backend-pytest | `cd backend && uv run pytest -q` | 5 | Backend regression tests pass | diff --git a/README-EN.md b/README-EN.md index cd24e83e..bfd4f8bb 100644 --- a/README-EN.md +++ b/README-EN.md @@ -8,7 +8,7 @@
A Simple and Universal Swarm Intelligence Engine, Predicting Anything -666ghj%2MiroFish | Shanda +666ghj%2FMiroFish | Shanda [![GitHub Stars](https://img.shields.io/github/stars/666ghj/MiroFish?style=flat-square&color=DAA520)](https://github.com/666ghj/MiroFish/stargazers) [![GitHub Watchers](https://img.shields.io/github/watchers/666ghj/MiroFish?style=flat-square)](https://github.com/666ghj/MiroFish/watchers) @@ -20,7 +20,7 @@ [![X](https://img.shields.io/badge/X-Follow-000000?style=flat-square&logo=x&logoColor=white)](https://x.com/mirofish_ai) [![Instagram](https://img.shields.io/badge/Instagram-Follow-E4405F?style=flat-square&logo=instagram&logoColor=white)](https://www.instagram.com/mirofish_ai/) -[English](./README-EN.md) | [中文文档](./README.md) +[English](./README-EN.md) | [中文文档](./README.md) | [한국어](./README-KO.md) | [日本語](./README-JA.md) | [Русский](./README-RU.md) @@ -91,6 +91,113 @@ Click the image to watch MiroFish's deep prediction of the lost ending based on 4. **Report Generation**: ReportAgent with rich toolset for deep interaction with post-simulation environment 5. **Deep Interaction**: Chat with any agent in the simulated world & Interact with ReportAgent +## 🏗️ System Architecture + +### Layer Breakdown + +| Layer | Core Modules | Responsibilities | +|------|--------------|------------------| +| Presentation | `frontend/src/views/*`, `frontend/src/components/*` | 5-step workflow UI, live simulation status, report and interaction pages | +| API | `backend/app/api/graph.py`, `simulation.py`, `report.py` | Public APIs for graph build, simulation control, report generation and download | +| Orchestration | `simulation_manager.py`, `simulation_runner.py` | Simulation state machine, process lifecycle, pause/resume/stop, live status aggregation | +| Memory & Graph | `graph_builder.py`, `zep_entity_reader.py`, `zep_graph_memory_updater.py` | Seed structuring, graph writing, entity filtering, and post-simulation memory writeback | +| Reasoning & Report | `report_agent.py`, `zep_tools.py`, `utils/llm_client.py` | ReACT multi-step reasoning, tool calls, and interactive prediction report generation | + +### Project Code Structure Tree + +```text +MiroFish/ +├── frontend/ # Vue3 frontend project +│ ├── package.json # frontend dependencies and scripts +│ ├── vite.config.js # Vite build/dev server config +│ ├── index.html # frontend HTML entry +│ └── src/ +│ ├── main.js # Vue app bootstrap +│ ├── App.vue # root component +│ ├── api/ # backend API wrappers +│ │ ├── index.js # Axios instance and shared request config +│ │ ├── graph.js # graph build related APIs +│ │ ├── simulation.js # simulation control APIs +│ │ └── report.js # report generation/download/chat APIs +│ ├── router/ +│ │ └── index.js # frontend routes +│ ├── store/ +│ │ └── pendingUpload.js # pending upload state store +│ ├── views/ # page-level views +│ │ ├── Home.vue # home page +│ │ ├── MainView.vue # main workflow container +│ │ ├── Process.vue # 5-step process page +│ │ ├── SimulationView.vue # simulation preparation page +│ │ ├── SimulationRunView.vue # live simulation monitor page +│ │ ├── ReportView.vue # report viewer page +│ │ └── InteractionView.vue # deep interaction page +│ ├── components/ # business components +│ │ ├── Step1GraphBuild.vue # Step1 graph build component +│ │ ├── Step2EnvSetup.vue # Step2 environment setup component +│ │ ├── Step3Simulation.vue # Step3 simulation component +│ │ ├── Step4Report.vue # Step4 report component +│ │ ├── Step5Interaction.vue # Step5 interaction component +│ │ ├── GraphPanel.vue # graph data panel +│ │ └── HistoryDatabase.vue # historical memory/data panel +│ └── assets/logo/ # frontend logo assets +│ ├── MiroFish_logo_left.jpeg +│ └── MiroFish_logo_compressed.jpeg +├── backend/ # Flask backend project +│ ├── run.py # backend service entrypoint +│ ├── requirements.txt # Python dependency list +│ ├── pyproject.toml # Python project metadata/tooling +│ ├── uv.lock # uv-locked dependency versions +│ ├── app/ +│ │ ├── __init__.py # Flask app factory and blueprint wiring +│ │ ├── config.py # backend config and env loading +│ │ ├── api/ # API route layer +│ │ │ ├── __init__.py # Blueprint initialization +│ │ │ ├── graph.py # graph build and graph management endpoints +│ │ │ ├── simulation.py # entity read, simulation create/run/control endpoints +│ │ │ └── report.py # report generate/query/download/chat endpoints +│ │ ├── services/ # core business services +│ │ │ ├── graph_builder.py # GraphRAG graph build service +│ │ │ ├── ontology_generator.py # ontology/entity type generation +│ │ │ ├── text_processor.py # seed text cleaning/preprocessing +│ │ │ ├── zep_entity_reader.py # Zep graph entity read/filter service +│ │ │ ├── oasis_profile_generator.py # OASIS persona/profile generation +│ │ │ ├── simulation_config_generator.py # simulation config auto-generation +│ │ │ ├── simulation_manager.py # simulation lifecycle state manager +│ │ │ ├── simulation_runner.py # background simulation execution/monitoring +│ │ │ ├── simulation_ipc.py # simulation process IPC protocol +│ │ │ ├── zep_graph_memory_updater.py # write simulation actions back to graph memory +│ │ │ ├── zep_tools.py # ReportAgent tool integrations +│ │ │ └── report_agent.py # ReACT report generation and Q&A service +│ │ ├── models/ # state model layer +│ │ │ ├── __init__.py +│ │ │ ├── project.py # project state and metadata manager +│ │ │ └── task.py # async task state model +│ │ └── utils/ # shared infrastructure utilities +│ │ ├── __init__.py +│ │ ├── llm_client.py # OpenAI-SDK-compatible LLM client +│ │ ├── file_parser.py # uploaded file parsing utilities +│ │ ├── logger.py # layered logging system +│ │ ├── retry.py # retry helpers/decorators +│ │ └── zep_paging.py # Zep paging helper +│ ├── scripts/ # OASIS runtime scripts +│ │ ├── run_parallel_simulation.py # Twitter + Reddit parallel simulation entry +│ │ ├── run_twitter_simulation.py # Twitter simulation runner +│ │ ├── run_reddit_simulation.py # Reddit simulation runner +│ │ ├── action_logger.py # agent action logging utility +│ │ └── test_profile_format.py # profile format validation script +│ ├── uploads/ # runtime data (projects/simulations/reports) +│ └── logs/ # backend runtime logs +├── static/ +│ └── image/ # README images and demo assets +├── package.json # root-level scripts for frontend/backend +├── docker-compose.yml # Docker orchestration (frontend + backend) +├── Dockerfile # Docker image build definition +├── .env.example # environment variable template +├── README.md # Chinese documentation +├── README-EN.md # English documentation +└── LICENSE # open-source license +``` + ## 🚀 Quick Start ### Option 1: Source Code Deployment (Recommended) @@ -115,23 +222,66 @@ cp .env.example .env **Required Environment Variables:** ```env -# LLM API Configuration (supports any LLM API with OpenAI SDK format) +# LLM API Configuration (supports OpenAI, Codex-compatible, and other OpenAI-SDK-compatible backends) +# Standard OPENAI_API_KEY / OPENAI_BASE_URL / OPENAI_API_BASE_URL / OPENAI_MODEL aliases also work # Recommended: Alibaba Qwen-plus model via Bailian Platform: https://bailian.console.aliyun.com/ # High consumption, try simulations with fewer than 40 rounds first LLM_API_KEY=your_api_key LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 LLM_MODEL_NAME=qwen-plus +# Optional: reduce this for OpenAI-compatible backends with smaller context windows +# LLM_MAX_TOKENS=4096 # Zep Cloud Configuration # Free monthly quota is sufficient for simple usage: https://app.getzep.com/ ZEP_API_KEY=your_zep_api_key ``` +The backend now accepts both the project-specific `LLM_*` variables and the standard `OPENAI_*` aliases, so you can point MiroFish directly at OpenAI, Codex-compatible gateways, LM Studio, Ollama, or other OpenAI-compatible backends without extra code changes or a separate `LLM_PROVIDER` flag. If multiple base-URL aliases are set, MiroFish resolves them in `LLM_BASE_URL` > `OPENAI_BASE_URL` > `OPENAI_API_BASE_URL` order; when those values disagree, `/api/graph/config/status` and `backend/scripts/print_config_status.py` now warn explicitly about which value won. + +Common compatible backend examples: + +```env +# OpenAI / Codex-compatible gateway +OPENAI_API_KEY=your_api_key +OPENAI_BASE_URL=https://api.openai.com/v1 +OPENAI_API_BASE_URL=https://api.openai.com/v1 +OPENAI_MODEL=gpt-4.1-mini + +# Alibaba DashScope Coding Plan +OPENAI_API_KEY=your_dashscope_key +OPENAI_API_BASE_URL=https://coding.dashscope.aliyuncs.com/v1 +OPENAI_MODEL=qwen3.5-plus +``` + +Verify the OpenAI-compatible path explicitly: + +- Visit `http://localhost:5001/health` to confirm the backend is up. +- Or run `npm run check:backend-config` to print the same non-sensitive config-status payload without starting the server. +- For the most direct local backend-only path, run `npm run backend:local`. It executes the same config preflight first and only starts Flask when the current `LLM_*` / `OPENAI_*` aliases resolve cleanly. +- Then open `http://localhost:5001/api/graph/config/status`. The JSON payload should report `llm.backend_mode = openai_compatible`. +- `summary.llm.sources` tells you whether MiroFish resolved `LLM_*` or `OPENAI_*` variables and whether the active base URL came from `OPENAI_BASE_URL` or `OPENAI_API_BASE_URL`, which is the quickest way to confirm a Codex/OpenAI-compatible gateway is wired correctly without adding `LLM_PROVIDER`. +- The same config-status payload now exposes `summary.capabilities`, which separates “direct LLM is ready” from “which workflow steps still need Zep”: `direct_llm` is the direct Codex/OpenAI-compatible path, `graph_build` and `graph_report_tools` map to Step 1 / Step 4, and `existing_simulation_interaction` shows whether Step 5 can still run once a Step 2/3 simulation environment already exists. +- If that config-status payload still lists `ZEP_API_KEY is not configured`, the direct LLM wiring is still working; it only means Step 1 graph build remains Zep-backed until a repo-native alternative graph backend is actually landed. +- A warning about `SECRET_KEY` being generated temporarily is expected in local verification shells when `SECRET_KEY` is unset; it does not mean the direct `OPENAI_*` wiring failed. + +If `http://localhost:5001` returns `404`, that usually does not mean the backend failed to boot. The backend root is API-only; use `http://localhost:5001/health` for a health check instead. + +If Step 5 deep interaction frequently times out for single-agent chat, batch surveys, or all-agent interviews, increase both the frontend request timeout `VITE_API_TIMEOUT` (milliseconds) and the backend Interview wait windows `INTERVIEW_AGENT_TIMEOUT_SECONDS`, `INTERVIEW_BATCH_TIMEOUT_SECONDS`, and `INTERVIEW_ALL_TIMEOUT_SECONDS` (seconds). + +For a first run, prefer a PDF / Markdown / TXT source under roughly 10k words and keep the simulation around 30 rounds. That lets you verify graph build, environment setup, and backend health before spending more Zep quota or debugging multiple scaling variables at once. + #### 2. Install Dependencies ```bash -# One-click installation of all dependencies (root + frontend + backend) +# Recommended core install for graph/report/OpenAI-compatible backend usage +npm run setup:core + +# Backward-compatible alias for the same core install path npm run setup:all + +# Install the optional OASIS runtime only if you need Step 3 / Step 5 simulations +npm run setup:backend:simulation ``` Or install step by step: @@ -140,21 +290,58 @@ Or install step by step: # Install Node dependencies (root + frontend) npm run setup -# Install Python dependencies (backend, auto-creates virtual environment) +# Install core Python dependencies (backend, auto-creates virtual environment) npm run setup:backend + +# Equivalent combined core install shortcut +npm run setup:core + +# Install the optional OASIS simulation runtime +npm run setup:backend:simulation ``` +`setup:core` / `setup:all` installs only the root package, frontend, and core graph/report/OpenAI-compatible backend dependencies. The upstream `oasis` runtime code used by Step 3 / Step 5 is now vendored directly under `backend/oasis`, and the optional simulation install keeps only the explicit runtime dependencies, so the default path no longer pulls the high-risk `camel-oasis -> unstructured==0.13.7` chain. + +Known limitation: `npm run setup:backend:simulation` now fails fast on Python 3.13+ when Rust is not installed, because the current `camel-ai -> tiktoken==0.7.0` chain still falls back to a source build there. The core backend path is unaffected; for actual Step 3 / Step 5 simulation runs, use Python 3.11/3.12 or install Rust before running that command. + #### 3. Start Services ```bash # Start both frontend and backend (run from project root) npm run dev + +# Or run only the backend with an OpenAI-compatible config preflight +npm run backend:local ``` **Service URLs:** - Frontend: `http://localhost:3000` - Backend API: `http://localhost:5001` +If you use the default dual-port layout, the frontend auto-targets backend port `5001` on the same host. The backend root is API-only; use `http://localhost:5001/health` for a quick health check. + +#### 3.1 FAQ + +**Which models / APIs are supported?** + +- The backend accepts any OpenAI-compatible API; it is not locked to one provider. +- Paths already validated and documented in this repo include OpenAI, Codex-compatible gateways, Alibaba DashScope compatible mode, Alibaba DashScope Coding Plan, and local OpenAI-compatible gateways such as LM Studio or Ollama. +- You can configure either the project-specific `LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL_NAME` variables or the standard `OPENAI_API_KEY` / `OPENAI_API_BASE_URL` / `OPENAI_MODEL` aliases directly. + +**What happens if I refresh the browser or close the page?** + +- Refreshing the browser or closing the tab does not directly stop graph-building, simulation, or report jobs that are already running on the backend. +- Persisted data remains under `backend/uploads/`, and the homepage history view can reopen Step 1 (Graph Build), Step 2 (Environment Setup), and Step 4 (Report). +- Step 3 and Step 5 still depend on a live OASIS runtime session. If the backend process, container, or simulation environment has already been shut down, those live runtime stages cannot be replayed seamlessly and must be prepared or started again. + +**How can I verify later whether a forecast matched real-world outcomes?** + +- MiroFish does not yet ship an automatic ground-truth ingester or accuracy scorer, but it already preserves a stable manual verification trail. +- In Step 4, keep the `report_id`, and use the homepage history entry to preserve the matching `simulation_id`. Those IDs anchor later review to the same graph, environment, and report artifacts. +- For offline evidence, export the Markdown report from Step 4 or keep the generated files under `backend/uploads/reports//full_report.md` and the sibling JSON metadata. +- Once the real event has evolved, reopen Step 4 from history and compare the report's main judgments, timelines, and assumptions against what actually happened. Reopen Step 1 / Step 2 as needed to inspect the original source material and setup. +- If the simulation runtime is still online, Step 5 can be used to ask the Report Agent or individual roles which assumptions were validated or invalidated. If the runtime session is gone, the current workflow is Step 4 evidence retention plus manual comparison rather than true automated backtesting. + **Start Individually:** ```bash @@ -162,19 +349,41 @@ npm run backend # Start backend only npm run frontend # Start frontend only ``` +#### 4. Lightweight Backend Validation + +If `uv sync` or `uv run pytest` is blocked by heavyweight builds such as `tiktoken` requiring a Rust toolchain, run the fast targeted backend suite instead: + +```bash +npm run test:backend:lite +``` + +This path creates `.tmp-test-venv/` on demand and installs only the packages needed for the current low-risk regression tests (`test_llm_client.py` and `test_graph_builder.py`). + ### Option 2: Docker Deployment ```bash # 1. Configure environment variables (same as source deployment) cp .env.example .env -# 2. Pull image and start +# 2. Optional: override the container image if GHCR is slow or blocked +# MIROFISH_IMAGE=ghcr.nju.edu.cn/666ghj/mirofish:latest + +# 3. Pull image and start docker compose up -d ``` Reads `.env` from root directory by default, maps ports `3000 (frontend) / 5001 (backend)` -> Mirror address for faster pulling is provided as comments in `docker-compose.yml`, replace if needed. +If you deploy frontend and backend on different hosts or ports, set `VITE_API_BASE_URL` for the frontend explicitly. +You can also open the `Backend API` panel in the home screen or the Step 1 / Step 2 workbench header and persist a runtime backend URL in the browser without rebuilding the frontend. + +For backend-side cross-origin control, you can also set `CORS_ALLOWED_ORIGINS` (comma-separated) plus optional `CORS_ALLOW_METHODS` / `CORS_ALLOW_HEADERS`. The default remains permissive (`*`) for backward compatibility, so these variables are only needed when you want to restrict which frontend origins may call `/api/*`. + +`docker-compose.yml` now reads `MIROFISH_IMAGE`, so you can switch to a registry mirror or a private fork image through `.env` or a one-shot shell override instead of editing the compose file. + +```bash +MIROFISH_IMAGE=ghcr.nju.edu.cn/666ghj/mirofish:latest docker compose up -d +``` ## 📬 Join the Conversation @@ -200,4 +409,4 @@ MiroFish's simulation engine is powered by **[OASIS (Open Agent Social Interacti Star History Chart - \ No newline at end of file + diff --git a/README-JA.md b/README-JA.md new file mode 100644 index 00000000..02944647 --- /dev/null +++ b/README-JA.md @@ -0,0 +1,273 @@ +
+ +MiroFish Logo + +666ghj%2FMiroFish | Trendshift + +シンプルで汎用的な群知能エンジン、あらゆるものを予測 +
+A Simple and Universal Swarm Intelligence Engine, Predicting Anything + +666ghj%2FMiroFish | Shanda + +[![GitHub Stars](https://img.shields.io/github/stars/666ghj/MiroFish?style=flat-square&color=DAA520)](https://github.com/666ghj/MiroFish/stargazers) +[![GitHub Watchers](https://img.shields.io/github/watchers/666ghj/MiroFish?style=flat-square)](https://github.com/666ghj/MiroFish/watchers) +[![GitHub Forks](https://img.shields.io/github/forks/666ghj/MiroFish?style=flat-square)](https://github.com/666ghj/MiroFish/network) +[![Docker](https://img.shields.io/badge/Docker-Build-2496ED?style=flat-square&logo=docker&logoColor=white)](https://hub.docker.com/) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/666ghj/MiroFish) + +[![Discord](https://img.shields.io/badge/Discord-Join-5865F2?style=flat-square&logo=discord&logoColor=white)](https://discord.com/channels/1469200078932545606/1469201282077163739) +[![X](https://img.shields.io/badge/X-Follow-000000?style=flat-square&logo=x&logoColor=white)](https://x.com/mirofish_ai) +[![Instagram](https://img.shields.io/badge/Instagram-Follow-E4405F?style=flat-square&logo=instagram&logoColor=white)](https://www.instagram.com/mirofish_ai/) + +[English](./README-EN.md) | [中文文档](./README.md) | [한국어](./README-KO.md) | [日本語](./README-JA.md) | [Русский](./README-RU.md) + +
+ +## ⚡ 概要 + +**MiroFish** は、マルチエージェント技術を活用した次世代AI予測エンジンです。現実世界からシード情報(ニュース速報、政策草案、金融シグナルなど)を抽出し、高精度なパラレルデジタルワールドを自動構築します。この空間内で、独自の性格・長期記憶・行動ロジックを持つ数千のインテリジェントエージェントが自由に相互作用し、社会的進化を遂げます。「神の視点」から動的に変数を注入することで、未来の軌跡を精密に推論できます — **デジタルサンドボックスで未来をリハーサルし、無数のシミュレーションを経て最適な意思決定を導き出します**。 + +> 必要な操作は:シード素材(データ分析レポートや興味深い小説など)をアップロードし、予測要件を自然言語で記述するだけ
+> MiroFish が返すもの:詳細な予測レポートと、深くインタラクティブな高精度デジタルワールド + +### 私たちのビジョン + +MiroFish は、現実を映し出す群知能ミラーの構築を目指しています。個体間の相互作用から生まれる集合的創発を捉えることで、従来の予測の限界を突破します: + +- **マクロレベル**:意思決定者のためのリハーサルラボとして、政策や広報をゼロリスクで検証可能 +- **ミクロレベル**:個人ユーザーのためのクリエイティブサンドボックス — 小説の結末推理から想像力豊かなシナリオの探索まで、すべてを楽しく、遊び心を持って体験可能 + +シリアスな予測から遊び心あるシミュレーションまで、あらゆる「もしも」の結果を可視化し、万物の予測を可能にします。 + +## 🌐 ライブデモ + +オンラインデモ環境をぜひご覧ください。トレンドの世論イベントに関する予測シミュレーションを体験できます:[mirofish-live-demo](https://666ghj.github.io/mirofish-demo/) + +## 📸 スクリーンショット + +
+ + + + + + + + + + + + + +
スクリーンショット 1スクリーンショット 2
スクリーンショット 3スクリーンショット 4
スクリーンショット 5スクリーンショット 6
+
+ +## 🎬 デモ動画 + +### 1. 武漢大学世論シミュレーション + MiroFish プロジェクト紹介 + +
+MiroFish デモ動画 + +画像をクリックして、BettaFish生成の「武漢大学世論レポート」を使用した予測の完全デモ動画をご覧ください +
+ +### 2. 紅楼夢 失われた結末シミュレーション + +
+MiroFish デモ動画 + +画像をクリックして、「紅楼夢」前80回の数十万字に基づくMiroFishの失われた結末の深層予測をご覧ください +
+ +> **金融予測**、**政治ニュース予測** などの事例を近日公開予定... + +## 🔄 ワークフロー + +1. **グラフ構築**:シード抽出 & 個体/集合記憶の注入 & GraphRAG構築 +2. **環境セットアップ**:エンティティ関係抽出 & ペルソナ生成 & エージェント設定注入 +3. **シミュレーション**:デュアルプラットフォーム並列シミュレーション & 予測要件の自動解析 & 動的時間記憶更新 +4. **レポート生成**:豊富なツールセットを持つReportAgentによるシミュレーション後環境との深いインタラクション +5. **深層インタラクション**:シミュレーション世界の任意のエージェントとチャット & ReportAgentとの対話 + +## 🚀 クイックスタート + +### オプション1:ソースコードデプロイ(推奨) + +#### 前提条件 + +| ツール | バージョン | 説明 | インストール確認 | +|------|---------|-------------|-------------------| +| **Node.js** | 18+ | フロントエンドランタイム、npm含む | `node -v` | +| **Python** | ≥3.11, ≤3.12 | バックエンドランタイム | `python --version` | +| **uv** | 最新版 | Pythonパッケージマネージャー | `uv --version` | + +#### 1. 環境変数の設定 + +```bash +# サンプル設定ファイルをコピー +cp .env.example .env + +# .envファイルを編集し、必要なAPIキーを入力 +``` + +**必須環境変数:** + +```env +# LLM API設定(OpenAI SDK形式の任意のLLM APIに対応) +# 標準の OPENAI_API_KEY / OPENAI_BASE_URL / OPENAI_API_BASE_URL / OPENAI_MODEL エイリアスも利用可能 +# 推奨:阿里雲百錬プラットフォーム経由のQwen-plusモデル: https://bailian.console.aliyun.com/ +# 消費量が多いため、まず40ラウンド未満のシミュレーションをお試しください +LLM_API_KEY=your_api_key +LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 +LLM_MODEL_NAME=qwen-plus + +# Zep Cloud設定 +# 無料の月間クォータで簡単な利用には十分です: https://app.getzep.com/ +ZEP_API_KEY=your_zep_api_key +``` + +バックエンドはプロジェクト固有の `LLM_*` 変数と標準の `OPENAI_*` エイリアスの両方を受け付けるため、OpenAI、Codex互換ゲートウェイ、LM Studio、Ollama、Alibaba DashScope Coding Plan などの OpenAI-compatible バックエンドへ、追加コードや `LLM_PROVIDER` フラグなしで直接接続できます。 + +よく使われる OpenAI-compatible 設定例: + +```env +# OpenAI / Codex-compatible gateway +OPENAI_API_KEY=your_api_key +OPENAI_BASE_URL=https://api.openai.com/v1 +OPENAI_API_BASE_URL=https://api.openai.com/v1 +OPENAI_MODEL=gpt-4.1-mini + +# Alibaba DashScope Coding Plan +OPENAI_API_KEY=your_dashscope_key +OPENAI_API_BASE_URL=https://coding.dashscope.aliyuncs.com/v1 +OPENAI_MODEL=qwen3.5-plus +``` + +OpenAI-compatible 設定として認識されたかを確認するには: + +- まず `http://localhost:5001/health` にアクセスし、バックエンドが起動していることを確認します。 +- または `npm run check:backend-config` を実行すると、サーバーを起動しなくても同じ非機密の config-status JSON を出力できます。 +- バックエンドだけを最短経路で起動したい場合は `npm run backend:local` を使います。同じ設定プリフライトを通してから Flask を起動するため、`LLM_*` / `OPENAI_*` の解決に失敗した状態で立ち上がりません。 +- 次に `http://localhost:5001/api/graph/config/status` を開きます。返却 JSON の `llm.backend_mode` は `openai_compatible` である必要があります。 +- `summary.llm.sources` には実際に採用された `LLM_*` または `OPENAI_*` 環境変数名が表示されるため、Codex / OpenAI / DashScope Coding Plan のような互換ゲートウェイを追加の `LLM_PROVIDER` なしで正しく認識できているか確認できます。 +- 同じ `config-status` には `summary.capabilities` も含まれ、「直接 LLM は利用可能」と「どの工程がまだ Zep を必要とするか」を分けて確認できます。`direct_llm` は Codex/OpenAI-compatible 直結経路、`graph_build` と `graph_report_tools` は Step 1 / Step 4、`existing_simulation_interaction` は Step 2/3 のシミュレーション環境が既にある場合に Step 5 を継続できるかを示します。 +- 同じ config-status に `ZEP_API_KEY is not configured` が残っていても、直接の `OPENAI_*` / Codex 互換 LLM 経路が壊れているわけではありません。現時点では Step 1 のグラフ構築がまだ Zep 依存で、リポジトリ標準の代替グラフバックエンドは未導入です。 +- ローカル検証で `SECRET_KEY` を設定していない場合、`npm run check:backend-config` に一時的な生成キーの warning が出ることがありますが、これは想定内であり、直接の `OPENAI_*` 接続失敗を意味しません。 + +#### 2. 依存関係のインストール + +```bash +# 推奨: コア依存関係をインストール(ルート + フロントエンド + バックエンド) +npm run setup:core + +# 後方互換エイリアス(setup:core と同じ) +npm run setup:all + +# Step 3 / Step 5 のシミュレーションが必要な場合のみ任意の OASIS ランタイムを追加 +npm run setup:backend:simulation +``` + +または段階的にインストール: + +```bash +# Node依存関係をインストール(ルート + フロントエンド) +npm run setup + +# Python コア依存関係をインストール(バックエンド、仮想環境を自動作成) +npm run setup:backend + +# OASIS シミュレーションランタイム用の任意依存関係をインストール +npm run setup:backend:simulation +``` + +`setup:core` は `setup` と `setup:backend` を順番に実行する推奨ショートカットです。`setup:all` は後方互換エイリアスとしてそのまま利用できます。 + +`setup:backend` は現在、グラフ構築・レポート生成・OpenAI-compatible バックエンド接続に必要なコア依存関係のみをインストールします。Step 3 / Step 5 で使う `oasis` ランタイムコードは `backend/oasis` に vendoring されており、任意のシミュレーション導入では実行時に必要な明示的パッケージだけを追加します。 + +既知の制限: `npm run setup:backend:simulation` は Python 3.13+ 環境で Rust が未導入だと失敗する場合があります。現在の `camel-ai -> tiktoken==0.7.0` チェーンがソースビルドへフォールバックするためです。コアバックエンドだけであれば既定のインストール経路で問題なく、実際に Step 3 / Step 5 シミュレーションを回す場合は Python 3.11/3.12 または Rust 導入を推奨します。 + +#### 3. サービスの起動 + +```bash +# フロントエンドとバックエンドを同時に起動(プロジェクトルートから実行) +npm run dev +``` + +**サービスURL:** +- フロントエンド: `http://localhost:3000` +- バックエンド API: `http://localhost:5001` + +デフォルトの 2 ポート構成では、フロントエンドは同一ホストのバックエンド `5001` を自動参照します。バックエンドのルートは API 用なので、疎通確認は `http://localhost:5001/health` を使用してください。 + +**ブラウザを更新したり閉じたりするとどうなりますか?** + +- 更新やタブのクローズだけで、すでにバックエンドで動いているグラフ構築・シミュレーション・レポート処理が自動停止することはありません。 +- 永続化済みデータは `backend/uploads/` 配下に残り、ホーム画面の履歴から Step 1 / Step 2 / Step 4 を再度開けます。 +- ただし Step 3 / Step 5 はライブのランタイムセッションが前提なので、その環境が閉じた後に履歴だけで完全再生や真のチェックポイント再開まではまだ対応していません。 + +**あとから予測が現実の結果と合っていたかをどう検証できますか?** + +- MiroFish にはまだ ground-truth の自動取り込みや精度スコアリング機能はありませんが、手動検証に必要な証跡はすでに残せます。 +- Step 4 では `report_id` を控え、ホーム画面の履歴では対応する `simulation_id` も保持してください。この 2 つの ID が、あとから同じグラフ、シミュレーション環境、レポート成果物へ再び結び付ける基準になります。 +- オフライン保管が必要な場合は、Step 4 から Markdown レポートをエクスポートするか、`backend/uploads/reports//full_report.md` と同じディレクトリにある JSON メタデータを保存してください。 +- 現実の出来事が進展したら、履歴から Step 4 を開き直して、レポート内の主要判断、タイムライン、前提条件を実際に起きた内容と照合してください。必要に応じて Step 1 / Step 2 の元資料や設定も見直せます。 +- シミュレーションランタイムがまだ動作中なら、Step 5 で Report Agent や各ロールに対して「どの前提が当たり、どこが外れたか」を追加で確認できます。ランタイムセッションが終了している場合、現時点の流れは自動 backtesting ではなく、Step 4 の証跡保存と手動比較が中心です。 + +**個別に起動:** + +```bash +npm run backend # バックエンドのみ起動 +npm run backend:local # 設定プリフライト付きでバックエンドのみ起動 +npm run frontend # フロントエンドのみ起動 +``` + +### オプション2:Dockerデプロイ + +```bash +# 1. 環境変数を設定(ソースデプロイと同様) +cp .env.example .env + +# 2. 任意: GHCR が遅い/失敗する場合はイメージを上書き +# MIROFISH_IMAGE=ghcr.nju.edu.cn/666ghj/mirofish:latest + +# 3. イメージをプルして起動 +docker compose up -d +``` + +デフォルトでルートディレクトリの `.env` を読み取り、ポート `3000(フロントエンド)/ 5001(バックエンド)` をマッピング + +フロントエンドとバックエンドを別ホストまたは別ポートで運用する場合は、フロントエンド側で `VITE_API_BASE_URL` を明示設定してください。 + +`docker-compose.yml` は `MIROFISH_IMAGE` を読むようになったため、`.env` または単発コマンドでミラー/私有レジストリへ切り替えられます。compose ファイルを手編集する必要はありません。 + +```bash +MIROFISH_IMAGE=ghcr.nju.edu.cn/666ghj/mirofish:latest docker compose up -d +``` + +## 📬 コミュニティに参加 + +
+QQグループ +
+ +  + +MiroFishチームはフルタイム/インターンシップのポジションを募集しています。マルチエージェントシミュレーションやLLMアプリケーションに興味がある方は、お気軽に履歴書をお送りください:**mirofish@shanda.com** + +## 📄 謝辞 + +**MiroFishは盛大グループから戦略的支援とインキュベーションを受けています!** + +MiroFishのシミュレーションエンジンは **[OASIS (Open Agent Social Interaction Simulations)](https://github.com/camel-ai/oasis)** を基盤としています。CAMEL-AIチームのオープンソース貢献に心より感謝いたします! + +## 📈 プロジェクト統計 + + + + + + Star History Chart + + diff --git a/README-KO.md b/README-KO.md new file mode 100644 index 00000000..336b5786 --- /dev/null +++ b/README-KO.md @@ -0,0 +1,275 @@ +
+ +MiroFish Logo + +666ghj%2FMiroFish | Trendshift + +간결하고 범용적인 군집 지능 엔진, 무엇이든 예측합니다 +
+A Simple and Universal Swarm Intelligence Engine, Predicting Anything + +666ghj%2FMiroFish | Shanda + +[![GitHub Stars](https://img.shields.io/github/stars/666ghj/MiroFish?style=flat-square&color=DAA520)](https://github.com/666ghj/MiroFish/stargazers) +[![GitHub Watchers](https://img.shields.io/github/watchers/666ghj/MiroFish?style=flat-square)](https://github.com/666ghj/MiroFish/watchers) +[![GitHub Forks](https://img.shields.io/github/forks/666ghj/MiroFish?style=flat-square)](https://github.com/666ghj/MiroFish/network) +[![Docker](https://img.shields.io/badge/Docker-Build-2496ED?style=flat-square&logo=docker&logoColor=white)](https://hub.docker.com/) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/666ghj/MiroFish) + +[![Discord](https://img.shields.io/badge/Discord-Join-5865F2?style=flat-square&logo=discord&logoColor=white)](https://discord.com/channels/1469200078932545606/1469201282077163739) +[![X](https://img.shields.io/badge/X-Follow-000000?style=flat-square&logo=x&logoColor=white)](https://x.com/mirofish_ai) +[![Instagram](https://img.shields.io/badge/Instagram-Follow-E4405F?style=flat-square&logo=instagram&logoColor=white)](https://www.instagram.com/mirofish_ai/) + +[English](./README-EN.md) | [中文文档](./README.md) | [한국어](./README-KO.md) | [日本語](./README-JA.md) | [Русский](./README-RU.md) + +
+ +## ⚡ 프로젝트 개요 + +**MiroFish**는 멀티 에이전트 기술 기반의 차세대 AI 예측 엔진입니다. 현실 세계의 시드 정보(속보, 정책 초안, 금융 신호 등)를 추출해 고충실도의 평행 디지털 세계를 자동으로 구성합니다. 이 공간 안에서 독립적인 성격, 장기 기억, 행동 로직을 가진 수많은 지능형 에이전트가 자유롭게 상호작용하며 사회적으로 진화합니다. 사용자는 "신의 시점"에서 변수를 동적으로 주입해 미래의 전개를 정밀하게 시뮬레이션할 수 있습니다. 즉, **디지털 샌드박스에서 미래를 미리 리허설하고, 수많은 시뮬레이션을 거쳐 더 나은 결정을 내릴 수 있게 해줍니다.** + +> 당신이 할 일은 간단합니다: 시드 자료(데이터 분석 보고서나 흥미로운 소설 이야기)를 업로드하고 자연어로 예측 요구를 설명하세요.
+> MiroFish는 다음을 반환합니다: 자세한 예측 보고서와 깊이 상호작용할 수 있는 고충실도 디지털 세계 + +### 우리의 비전 + +MiroFish는 현실을 비추는 군집 지능의 거울을 만드는 것을 목표로 합니다. 개별 상호작용이 만들어내는 집단적 창발을 포착해 기존 예측 방식의 한계를 넘어섭니다. + +- **거시적 관점**: 정책과 홍보 전략을 무위험 환경에서 시험할 수 있는 의사결정자용 리허설 실험실 +- **미시적 관점**: 소설 결말을 추론하거나 상상력을 실험하는 등 누구나 재미있고 쉽게 활용할 수 있는 개인용 창작 샌드박스 + +진지한 예측부터 흥미로운 시뮬레이션까지, MiroFish는 모든 "만약"에 대한 결과를 먼저 보여주며 무엇이든 예측할 수 있는 가능성을 열어 줍니다. + +## 🌐 온라인 데모 + +온라인 데모 환경에서 준비된 화제성 여론 사건 예측 시뮬레이션을 직접 체험해 보세요: [mirofish-live-demo](https://666ghj.github.io/mirofish-demo/) + +## 📸 시스템 스크린샷 + +
+ + + + + + + + + + + + + +
스크린샷 1스크린샷 2
스크린샷 3스크린샷 4
스크린샷 5스크린샷 6
+
+ +## 🎬 데모 영상 + +### 1. 우한대학교 여론 시뮬레이션 예측 + MiroFish 프로젝트 소개 + +
+MiroFish Demo Video + +이미지를 클릭하면 BettaFish가 생성한 "우한대학교 여론 보고서"를 기반으로 예측을 수행하는 전체 데모 영상을 볼 수 있습니다. +
+ +### 2. 《홍루몽》 유실 결말 예측 시뮬레이션 + +
+MiroFish Demo Video + +이미지를 클릭하면 《홍루몽》 앞 80회 분량의 방대한 텍스트를 바탕으로 MiroFish가 유실 결말을 심층 예측하는 영상을 볼 수 있습니다. +
+ +> **금융 예측**, **정치/시사 뉴스 예측** 등 더 많은 예제가 계속 추가될 예정입니다. + +## 🔄 워크플로우 + +1. **그래프 구축**: 현실 시드 추출 · 개인/집단 기억 주입 · GraphRAG 구축 +2. **환경 구성**: 엔터티 관계 추출 · 페르소나 생성 · 환경 설정 에이전트의 시뮬레이션 파라미터 주입 +3. **시뮬레이션 시작**: 양대 플랫폼 병렬 시뮬레이션 · 예측 요구 자동 해석 · 시간 기반 기억 동적 갱신 +4. **보고서 생성**: ReportAgent가 풍부한 도구 세트로 시뮬레이션 이후 환경과 깊이 상호작용 +5. **심층 인터랙션**: 시뮬레이션 세계 속 개체 또는 ReportAgent와 대화 + +## 🚀 빠른 시작 + +### 1. 소스코드 배포 (권장) + +#### 사전 요구사항 + +| 도구 | 버전 요구사항 | 설명 | 설치 확인 | +|------|--------------|------|-----------| +| **Node.js** | 18+ | 프런트엔드 실행 환경, npm 포함 | `node -v` | +| **Python** | ≥3.11, ≤3.12 | 백엔드 실행 환경 | `python --version` | +| **uv** | 최신 버전 | Python 패키지 관리자 | `uv --version` | + +#### 1) 환경 변수 설정 + +```bash +# 예시 설정 파일 복사 +cp .env.example .env + +# .env 파일을 열어 필요한 API 키 입력 +``` + +**필수 환경 변수** + +```env +# LLM API 설정 (OpenAI SDK 형식을 지원하는 모든 LLM API 사용 가능) +# 표준 OPENAI_API_KEY / OPENAI_BASE_URL / OPENAI_API_BASE_URL / OPENAI_MODEL 별칭도 지원합니다 +# 권장: 알리바바 Bailian Platform의 qwen-plus 모델 +# https://bailian.console.aliyun.com/ +# 비용 소모가 클 수 있으므로 처음에는 40라운드 미만으로 테스트를 권장합니다. +LLM_API_KEY=your_api_key +LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 +LLM_MODEL_NAME=qwen-plus + +# Zep Cloud 설정 +# 간단한 사용에는 월간 무료 할당량으로 충분합니다. +# https://app.getzep.com/ +ZEP_API_KEY=your_zep_api_key +``` + +백엔드는 프로젝트 전용 `LLM_*` 변수와 표준 `OPENAI_*` 별칭을 모두 인식하므로, OpenAI, Codex 호환 게이트웨이, LM Studio, Ollama, Alibaba DashScope Coding Plan 같은 OpenAI-compatible 백엔드에 추가 코드나 `LLM_PROVIDER` 플래그 없이 바로 연결할 수 있습니다. + +자주 쓰는 OpenAI-compatible 설정 예시: + +```env +# OpenAI / Codex-compatible gateway +OPENAI_API_KEY=your_api_key +OPENAI_BASE_URL=https://api.openai.com/v1 +OPENAI_API_BASE_URL=https://api.openai.com/v1 +OPENAI_MODEL=gpt-4.1-mini + +# Alibaba DashScope Coding Plan +OPENAI_API_KEY=your_dashscope_key +OPENAI_API_BASE_URL=https://coding.dashscope.aliyuncs.com/v1 +OPENAI_MODEL=qwen3.5-plus +``` + +OpenAI-compatible 설정이 실제로 인식됐는지 확인하려면: + +- 먼저 `http://localhost:5001/health` 에 접속해 백엔드가 정상 기동했는지 확인합니다. +- 또는 `npm run check:backend-config` 를 실행하면 서버를 띄우지 않고도 같은 비민감 config-status JSON 을 바로 확인할 수 있습니다. +- 백엔드만 가장 직접적인 경로로 띄우고 싶다면 `npm run backend:local` 을 사용하세요. 동일한 설정 프리플라이트를 먼저 통과한 뒤 Flask 를 시작하므로 `LLM_*` / `OPENAI_*` 해석이 잘못된 상태로 서버가 올라가지 않습니다. +- 다음으로 `http://localhost:5001/api/graph/config/status` 를 엽니다. 반환 JSON 의 `llm.backend_mode` 값은 `openai_compatible` 이어야 합니다. +- `summary.llm.sources` 는 실제로 적용된 `LLM_*` 또는 `OPENAI_*` 환경 변수 이름을 보여 주므로, Codex / OpenAI / DashScope Coding Plan 같은 호환 게이트웨이가 추가 `LLM_PROVIDER` 없이 올바르게 인식됐는지 바로 확인할 수 있습니다. +- 같은 config-status 에는 이제 `summary.capabilities` 도 포함되어, "직접 LLM 경로는 준비됨" 과 "어떤 워크플로 단계가 아직 Zep 을 필요로 하는지" 를 분리해서 보여 줍니다. `direct_llm` 은 Codex/OpenAI-compatible 직결 경로를 뜻하고, `graph_build` 와 `graph_report_tools` 는 Step 1 / Step 4 를 가리키며, `existing_simulation_interaction` 은 Step 2/3 시뮬레이션 환경이 이미 있으면 Step 5 를 계속 진행할 수 있는지 보여 줍니다. +- 같은 config-status 에서 `ZEP_API_KEY is not configured` 경고가 남아 있어도 직접 `OPENAI_*` / Codex 호환 LLM 연결이 실패했다는 뜻은 아닙니다. 현재 Step 1 그래프 빌드는 아직 Zep 에 의존하며, 저장소 기본 대체 그래프 백엔드는 아직 미도입 상태입니다. +- 로컬 검증 셸에서 `SECRET_KEY` 를 설정하지 않았다면 `npm run check:backend-config` 에 임시 생성 키 warning 이 나올 수 있는데, 이는 정상이며 직접 `OPENAI_*` 경로가 실패했다는 뜻은 아닙니다. + +#### 2) 의존성 설치 + +```bash +# 권장: 코어 의존성 설치 (루트 + 프런트엔드 + 백엔드) +npm run setup:core + +# 하위 호환 별칭 (setup:core 와 동일) +npm run setup:all + +# Step 3 / Step 5 시뮬레이션이 필요할 때만 선택적 OASIS 런타임 설치 +npm run setup:backend:simulation +``` + +또는 단계별 설치: + +```bash +# Node 의존성 설치 (루트 + 프런트엔드) +npm run setup + +# Python 핵심 의존성 설치 (백엔드, 가상환경 자동 생성) +npm run setup:backend + +# OASIS 시뮬레이션 런타임용 선택 의존성 설치 +npm run setup:backend:simulation +``` + +`setup:core` 는 `setup` 과 `setup:backend` 를 순서대로 실행하는 권장 단축 명령입니다. `setup:all` 은 같은 동작을 유지하는 하위 호환 별칭입니다. + +`setup:backend` 는 이제 그래프 구축, 보고서 생성, OpenAI-compatible 백엔드 연결에 필요한 핵심 의존성만 설치합니다. Step 3 / Step 5 에서 사용하는 `oasis` 런타임 코드는 `backend/oasis` 에 vendoring 되어 있고, 선택적 시뮬레이션 설치는 실제 런타임에 필요한 명시적 패키지만 추가합니다. + +알려진 제한: `npm run setup:backend:simulation` 은 Python 3.13+ 환경에서 Rust 가 없으면 실패할 수 있습니다. 현재 `camel-ai -> tiktoken==0.7.0` 체인이 소스 빌드를 요구하기 때문입니다. 코어 백엔드만 필요하다면 기본 설치 경로를 사용하고, 실제 Step 3 / Step 5 시뮬레이션이 필요하면 Python 3.11/3.12 또는 Rust 설치를 권장합니다. + +#### 3) 서비스 실행 + +```bash +# 프런트엔드와 백엔드를 동시에 실행 (프로젝트 루트에서 실행) +npm run dev +``` + +**서비스 주소** +- 프런트엔드: `http://localhost:3000` +- 백엔드 API: `http://localhost:5001` + +기본 2포트 구성에서는 프런트엔드가 같은 호스트의 백엔드 `5001` 포트를 자동으로 사용합니다. 백엔드 루트는 API 전용이므로 상태 확인은 `http://localhost:5001/health` 를 사용하세요. + +**브라우저를 새로고침하거나 닫으면 어떻게 되나요?** + +- 새로고침하거나 탭을 닫아도 이미 백엔드에서 실행 중인 그래프 구축, 시뮬레이션, 보고서 작업이 자동으로 중단되지는 않습니다. +- 저장된 데이터는 `backend/uploads/` 아래에 남아 있으며, 홈 화면의 히스토리에서 Step 1 / Step 2 / Step 4 를 다시 열 수 있습니다. +- 다만 Step 3 / Step 5 는 라이브 런타임 세션이 필요하므로, 해당 환경이 종료된 뒤에는 히스토리만으로 완전한 재생이나 체크포인트 재개까지 지원되지는 않습니다. + +**나중에 예측이 실제 결과와 맞았는지 어떻게 검증하나요?** + +- MiroFish 는 아직 실제 결과를 자동으로 수집하거나 정확도를 점수화하는 내장 평가기를 제공하지 않지만, 수동 검증에 필요한 증거 흐름은 이미 보존할 수 있습니다. +- Step 4 에서 `report_id` 를 기록하고, 홈 화면 히스토리에서 연결된 `simulation_id` 를 함께 남겨 두세요. 이 두 ID 가 같은 그래프, 시뮬레이션 환경, 보고서 산출물을 나중 검토와 연결해 줍니다. +- 오프라인 증거 보관이 필요하면 Step 4 에서 Markdown 보고서를 내보내거나 `backend/uploads/reports//full_report.md` 와 같은 폴더의 JSON 메타데이터를 함께 보관하세요. +- 실제 사건이 전개된 뒤에는 히스토리에서 Step 4 를 다시 열어 보고서의 핵심 판단, 타임라인, 전제 조건을 실제 결과와 하나씩 비교하세요. 필요하면 Step 1 / Step 2 의 원본 자료와 설정도 다시 확인할 수 있습니다. +- 시뮬레이션 런타임이 아직 살아 있다면 Step 5 에서 Report Agent 나 개별 역할에게 어떤 가정이 맞았고 무엇이 빗나갔는지 추가로 물을 수 있습니다. 런타임 세션이 종료됐다면 현재 워크플로는 자동 backtesting 이 아니라 Step 4 산출물 보존과 수동 비교가 중심입니다. + +**개별 실행** + +```bash +npm run backend # 백엔드만 실행 +npm run backend:local # 설정 프리플라이트 후 백엔드만 실행 +npm run frontend # 프런트엔드만 실행 +``` + +### 2. Docker 배포 + +```bash +# 1. 환경 변수 설정 (소스코드 배포와 동일) +cp .env.example .env + +# 2. 선택: GHCR 속도가 느리거나 실패하면 이미지 주소를 덮어쓰기 +# MIROFISH_IMAGE=ghcr.nju.edu.cn/666ghj/mirofish:latest + +# 3. 이미지 가져오기 및 실행 +docker compose up -d +``` + +기본적으로 루트 디렉터리의 `.env`를 읽고 `3000(프런트엔드) / 5001(백엔드)` 포트를 매핑합니다. + +프런트엔드와 백엔드를 서로 다른 호스트나 포트에 배포한다면 프런트엔드에서 `VITE_API_BASE_URL` 을 명시적으로 설정하세요. + +`docker-compose.yml` 이 이제 `MIROFISH_IMAGE` 를 읽으므로 `.env` 나 단일 실행 명령으로 미러/사설 레지스트리 이미지를 선택할 수 있습니다. compose 파일을 직접 수정할 필요가 없습니다. + +```bash +MIROFISH_IMAGE=ghcr.nju.edu.cn/666ghj/mirofish:latest docker compose up -d +``` + +## 📬 커뮤니티 및 문의 + +
+커뮤니티 그룹 +
+ +  + +MiroFish 팀은 상시로 정규직/인턴을 모집하고 있습니다. 멀티 에이전트 애플리케이션에 관심이 있다면 아래 메일로 이력서를 보내 주세요: **mirofish@shanda.com** + +## 📄 감사의 말 + +**MiroFish는 Shanda Group의 전략적 지원과 인큐베이팅을 받고 있습니다!** + +MiroFish의 시뮬레이션 엔진은 **[OASIS](https://github.com/camel-ai/oasis)** 기반으로 동작합니다. CAMEL-AI 팀의 오픈소스 기여에 깊이 감사드립니다. + +## 📈 프로젝트 통계 + + + + + + Star History Chart + + diff --git a/README-RU.md b/README-RU.md new file mode 100644 index 00000000..94b95f29 --- /dev/null +++ b/README-RU.md @@ -0,0 +1,208 @@ +
+ +MiroFish Logo + +666ghj%2FMiroFish | Trendshift + +Простой и универсальный движок коллективного интеллекта для прогнозирования чего угодно +
+A Simple and Universal Swarm Intelligence Engine, Predicting Anything + +666ghj%2FMiroFish | Shanda + +[![GitHub Stars](https://img.shields.io/github/stars/666ghj/MiroFish?style=flat-square&color=DAA520)](https://github.com/666ghj/MiroFish/stargazers) +[![GitHub Watchers](https://img.shields.io/github/watchers/666ghj/MiroFish?style=flat-square)](https://github.com/666ghj/MiroFish/watchers) +[![GitHub Forks](https://img.shields.io/github/forks/666ghj/MiroFish?style=flat-square)](https://github.com/666ghj/MiroFish/network) +[![Docker](https://img.shields.io/badge/Docker-Build-2496ED?style=flat-square&logo=docker&logoColor=white)](https://hub.docker.com/) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/666ghj/MiroFish) + +[![Discord](https://img.shields.io/badge/Discord-Join-5865F2?style=flat-square&logo=discord&logoColor=white)](https://discord.com/channels/1469200078932545606/1469201282077163739) +[![X](https://img.shields.io/badge/X-Follow-000000?style=flat-square&logo=x&logoColor=white)](https://x.com/mirofish_ai) +[![Instagram](https://img.shields.io/badge/Instagram-Follow-E4405F?style=flat-square&logo=instagram&logoColor=white)](https://www.instagram.com/mirofish_ai/) + +[English](./README-EN.md) | [中文文档](./README.md) | [한국어](./README-KO.md) | [日本語](./README-JA.md) | [Русский](./README-RU.md) + +
+ +## Обзор + +**MiroFish** — это мультиагентный движок прогнозирования, который строит высокодетализированный цифровой мир из исходных материалов: новостей, проектов политик, исследований или длинных текстов. Внутри этого мира множество агентов с памятью и поведенческими правилами взаимодействуют, развиваются и создают симуляцию, которую затем можно изучать через отчёты и прямой чат. + +Этот `README` на русском языке — безопасный репо-нативный поднабор документации, выделенный из upstream PR `#147`. Он описывает текущее состояние этой ветки без переноса крупной переработки фронтенда и бэкенда из того PR. + +## Что потребуется + +- Node.js `18+` +- Python `3.11+` +- `uv` +- API-ключ Zep +- OpenAI-compatible LLM endpoint + +## Быстрый старт + +```bash +cp .env.example .env +npm run setup:core +npm run dev +``` + +`npm run setup:all` по-прежнему доступен как обратно совместимый алиас для того же базового пути установки. `npm run setup:backend:simulation` нужен только тогда, когда вам действительно требуется опциональный рантайм симуляции для Step 3 / Step 5. + +Если нужен самый прямой путь запуска только бэкенда с тем же предварительным config-check, используйте `npm run backend:local`. Эта команда сначала выполняет `npm run check:backend-config` и запускает Flask только после того, как текущие алиасы `LLM_*` / `OPENAI_*` успешно разобраны. + +Сервисы: + +- Frontend: `http://localhost:3000` +- Backend API: `http://localhost:5001` + +## OpenAI-Compatible Бэкенды + +MiroFish может работать с любым бэкендом, который поддерживает OpenAI-compatible API `chat/completions`. Для настройки можно использовать либо репо-нативные переменные `LLM_*`, либо стандартные алиасы `OPENAI_*`. + +Пример: + +```env +OPENAI_API_KEY=your_api_key +OPENAI_BASE_URL=https://api.openai.com/v1 +OPENAI_API_BASE_URL=https://api.openai.com/v1 +OPENAI_MODEL=gpt-4.1-mini + +ZEP_API_KEY=your_zep_key +``` + +Эквивалентный вариант через `LLM_*`: + +```env +LLM_API_KEY=your_api_key +LLM_BASE_URL=https://api.openai.com/v1 +LLM_MODEL_NAME=gpt-4.1 +``` + +Бэкенд принимает как проектные переменные `LLM_*`, так и стандартные алиасы `OPENAI_*`, поэтому MiroFish можно напрямую подключить к OpenAI, Codex-compatible шлюзам, LM Studio, Ollama, DashScope или любому другому OpenAI-compatible бэкенду без дополнительных изменений кода и без отдельного флага `LLM_PROVIDER`. + +Часто используемые примеры совместимых бэкендов: + +```env +# OpenAI / Codex-compatible шлюз +OPENAI_API_KEY=your_api_key +OPENAI_BASE_URL=https://api.openai.com/v1 +OPENAI_API_BASE_URL=https://api.openai.com/v1 +OPENAI_MODEL=gpt-4.1-mini + +# Alibaba DashScope Coding Plan +OPENAI_API_KEY=your_dashscope_key +OPENAI_API_BASE_URL=https://coding.dashscope.aliyuncs.com/v1 +OPENAI_MODEL=qwen3.5-plus +``` + +Как проверить, что MiroFish распознал прямой OpenAI-compatible путь: + +- Откройте `http://localhost:5001/health`, чтобы убедиться, что бэкенд запущен. +- Или выполните `npm run check:backend-config`, чтобы вывести тот же не содержащий секретов JSON `config-status`, не поднимая сервер. +- Если нужен самый прямой путь запуска только бэкенда, используйте `npm run backend:local`. Он применяет тот же preflight, поэтому MiroFish не запустит Flask с некорректной конфигурацией `LLM_*` / `OPENAI_*`. +- Затем откройте `http://localhost:5001/api/graph/config/status`. В JSON-ответе значение `llm.backend_mode` должно быть `openai_compatible`. +- Поле `summary.llm.sources` показывает, были ли использованы переменные `LLM_*` или `OPENAI_*`, а также какой именно base URL победил: `OPENAI_BASE_URL` или `OPENAI_API_BASE_URL`. Это самый быстрый способ проверить, что Codex/OpenAI-compatible шлюз определился корректно без добавления `LLM_PROVIDER`. +- То же `config-status` теперь содержит `summary.capabilities`, где явно разделены состояния «прямой LLM уже готов» и «какие шаги всё ещё зависят от Zep»: `direct_llm` отвечает за прямой путь Codex/OpenAI-compatible, `graph_build` и `graph_report_tools` соответствуют Step 1 / Step 4, а `existing_simulation_interaction` показывает, можно ли продолжить Step 5, если окружение симуляции из Step 2/3 уже существует. +- Если в том же `config-status` всё ещё указано `ZEP_API_KEY is not configured`, это не означает, что прямое OpenAI-compatible LLM-подключение сломано. Это лишь означает, что Step 1 по-прежнему требует Zep, пока в репозитории не появился альтернативный графовый бэкенд. +- Если во время локальной проверки `SECRET_KEY` не задан, предупреждение о временно сгенерированном ключе ожидаемо и не означает, что прямой путь через `OPENAI_*` не работает. + +Если `http://localhost:5001` возвращает `404`, это обычно не означает, что бэкенд не запустился. Корневой путь бэкенда обслуживает только API, поэтому для проверки состояния используйте `http://localhost:5001/health`. + +Если Step 5 при одиночном диалоге, пакетных опросах или интервью со всеми агентами часто упирается в timeout, увеличьте и фронтендовый таймаут `VITE_API_TIMEOUT` (миллисекунды), и backend-параметры `INTERVIEW_AGENT_TIMEOUT_SECONDS`, `INTERVIEW_BATCH_TIMEOUT_SECONDS`, `INTERVIEW_ALL_TIMEOUT_SECONDS` (секунды). + +Для первого запуска лучше брать PDF / Markdown / TXT примерно до 10k слов и держать симуляцию около 30 раундов. Так проще сначала подтвердить сборку графа, настройку окружения и здоровье бэкенда, не тратя лишнюю квоту Zep и не отлаживая сразу несколько масштабных переменных. + +## Установка зависимостей + +```bash +# Рекомендуемый базовый путь для графа / отчётов / OpenAI-compatible backend +npm run setup:core + +# Обратно совместимый алиас для того же базового пути +npm run setup:all + +# Ставьте OASIS runtime только если нужен Step 3 / Step 5 +npm run setup:backend:simulation +``` + +Или по шагам: + +```bash +# Node-зависимости (root + frontend) +npm run setup + +# Основные Python-зависимости backend +npm run setup:backend + +# Эквивалентная сокращённая команда +npm run setup:core + +# Опциональный simulation runtime +npm run setup:backend:simulation +``` + +`setup:core` / `setup:all` устанавливает только root-пакет, frontend и основные backend-зависимости для графа, отчётов и прямого OpenAI-compatible подключения. Vendored-код `backend/oasis` уже лежит в репозитории, а дополнительная simulation-установка подтягивает только явные runtime-зависимости. + +Известное ограничение: `npm run setup:backend:simulation` может завершиться ошибкой на Python `3.13+`, если в системе нет Rust, потому что текущая цепочка `camel-ai -> tiktoken==0.7.0` всё ещё иногда переходит к source build. Базовый backend-путь это не затрагивает; для реальных Step 3 / Step 5 прогонов лучше использовать Python `3.11`/`3.12` или заранее установить Rust. + +## Запуск сервисов + +```bash +# Запустить frontend и backend вместе +npm run dev + +# Или только backend с preflight-проверкой OpenAI-compatible конфигурации +npm run backend:local +``` + +Если используется стандартная схема с двумя портами, frontend по умолчанию обращается к backend на `5001` на том же хосте. + +## FAQ + +**Какие модели и API поддерживаются?** + +- Backend принимает любой OpenAI-compatible API и не привязан к одному вендору. +- В текущей ветке уже проверялись OpenAI, Codex-compatible шлюзы, Alibaba DashScope compatible mode, Alibaba DashScope Coding Plan, а также локальные OpenAI-compatible gateway вроде LM Studio и Ollama. +- Можно использовать либо репо-нативные `LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL_NAME`, либо стандартные `OPENAI_API_KEY` / `OPENAI_API_BASE_URL` / `OPENAI_MODEL`. + +**Что будет, если обновить страницу или закрыть вкладку?** + +- Обновление страницы или закрытие вкладки не останавливает уже запущенные backend-задачи графа, симуляции или отчёта. +- Сохранённые данные остаются в `backend/uploads/`, а история на главной странице может снова открыть Step 1, Step 2 и Step 4. +- Step 3 и Step 5 всё ещё зависят от живой OASIS runtime-сессии. Если backend-процесс, контейнер или среда симуляции уже остановлены, эти этапы нельзя бесшовно воспроизвести, их нужно подготавливать или запускать заново. + +**Как потом проверить, совпал ли прогноз с реальными событиями?** + +- В MiroFish пока нет встроенного загрузчика ground-truth-данных или автоматического скоринга точности, но ручную цепочку проверки уже можно сохранить. +- В Step 4 сохраните `report_id`, а в истории на главной странице удерживайте связанный `simulation_id`. Эти идентификаторы привязывают последующую проверку к одному и тому же графу, окружению и артефактам отчёта. +- Для офлайн-архива можно экспортировать Markdown-отчёт из Step 4 или сохранить файлы из `backend/uploads/reports//full_report.md` и соседний JSON с метаданными. +- Когда у реального события появится продолжение, снова откройте Step 4 из истории и сверяйте ключевые выводы, таймлайн и допущения отчёта с тем, что произошло на самом деле. При необходимости можно параллельно пересмотреть исходный материал и настройки из Step 1 / Step 2. +- Если среда симуляции ещё работает, в Step 5 можно дополнительно спросить Report Agent или отдельных ролей, какие предпосылки подтвердились, а какие нет. Если runtime-сессия уже завершена, текущий поток остаётся ручным: сохранение артефактов Step 4 и последующее сравнение без автоматического backtesting. + +## Быстрая backend-проверка + +Если `uv sync` или `uv run pytest` блокируются тяжёлыми сборками вроде `tiktoken`, которым нужен Rust toolchain, используйте встроенный лёгкий backend-набор: + +```bash +npm run test:backend:lite +``` + +## Рабочий процесс + +1. Построить граф из исходного материала. +2. Сгенерировать конфигурацию окружения и персон. +3. Запустить симуляцию. +4. Сгенерировать отчёт. +5. Взаимодействовать с симулированным миром. + +## Полная документация + +- Руководство на английском: [README-EN.md](./README-EN.md) +- Руководство на китайском: [README.md](./README.md) +- Шаблон переменных окружения: [`.env.example`](./.env.example) +- Руководство по внесению изменений: [CONTRIBUTING.md](./CONTRIBUTING.md) + +## Примечания + +- Интерфейс продукта в этой ветке по-прежнему в основном остаётся китайско-английским. +- Upstream PR `#147` содержит гораздо более крупную попытку русской локализации, но безопасно cherry-pick'нуть его целиком нельзя: он заменяет большие части репозитория и удаляет текущие локальные проверки и tooling. diff --git a/README.md b/README.md index a47976c4..17c99b23 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@
A Simple and Universal Swarm Intelligence Engine, Predicting Anything -666ghj%2MiroFish | Shanda +666ghj%2FMiroFish | Shanda [![GitHub Stars](https://img.shields.io/github/stars/666ghj/MiroFish?style=flat-square&color=DAA520)](https://github.com/666ghj/MiroFish/stargazers) [![GitHub Watchers](https://img.shields.io/github/watchers/666ghj/MiroFish?style=flat-square)](https://github.com/666ghj/MiroFish/watchers) @@ -20,7 +20,7 @@ [![X](https://img.shields.io/badge/X-Follow-000000?style=flat-square&logo=x&logoColor=white)](https://x.com/mirofish_ai) [![Instagram](https://img.shields.io/badge/Instagram-Follow-E4405F?style=flat-square&logo=instagram&logoColor=white)](https://www.instagram.com/mirofish_ai/) -[English](./README-EN.md) | [中文文档](./README.md) +[English](./README-EN.md) | [中文文档](./README.md) | [한국어](./README-KO.md) | [日本語](./README-JA.md) | [Русский](./README-RU.md) @@ -91,6 +91,113 @@ MiroFish 致力于打造映射现实的群体智能镜像,通过捕捉个体 4. **报告生成**:ReportAgent拥有丰富的工具集与模拟后环境进行深度交互 5. **深度互动**:与模拟世界中的任意一位进行对话 & 与ReportAgent进行对话 +## 🏗️ 系统架构 + +### 分层说明 + +| 层级 | 核心模块 | 职责 | +|------|----------|------| +| 表现层 | `frontend/src/views/*`、`frontend/src/components/*` | 五步流程 UI、实时模拟状态展示、报告与深度交互页面 | +| API 层 | `backend/app/api/graph.py`、`simulation.py`、`report.py` | 对外提供图谱构建、模拟控制、报告生成与下载接口 | +| 编排层 | `simulation_manager.py`、`simulation_runner.py` | 模拟状态机、进程管理、暂停/恢复/停止与实时状态汇总 | +| 记忆与图谱层 | `graph_builder.py`、`zep_entity_reader.py`、`zep_graph_memory_updater.py` | 种子数据结构化、图谱写入、实体过滤与模拟后记忆回灌 | +| 推理与报告层 | `report_agent.py`、`zep_tools.py`、`utils/llm_client.py` | ReACT 多轮推理、工具调用、自动生成可交互预测报告 | + +### 项目代码结构树 + +```text +MiroFish/ +├── frontend/ # Vue3 前端工程 +│ ├── package.json # 前端依赖与脚本定义 +│ ├── vite.config.js # Vite 构建与开发服务配置 +│ ├── index.html # 前端入口 HTML 模板 +│ └── src/ +│ ├── main.js # Vue 应用启动入口 +│ ├── App.vue # 根组件 +│ ├── api/ # 后端接口封装层 +│ │ ├── index.js # Axios 实例与统一请求配置 +│ │ ├── graph.js # 图谱构建相关 API +│ │ ├── simulation.js # 模拟流程控制 API +│ │ └── report.js # 报告生成/下载/对话 API +│ ├── router/ +│ │ └── index.js # 前端路由配置 +│ ├── store/ +│ │ └── pendingUpload.js # 待上传文件状态管理 +│ ├── views/ # 页面级视图 +│ │ ├── Home.vue # 首页(项目介绍与入口) +│ │ ├── MainView.vue # 主流程容器页 +│ │ ├── Process.vue # 五步流程总览页 +│ │ ├── SimulationView.vue # 模拟准备页 +│ │ ├── SimulationRunView.vue # 模拟运行监控页 +│ │ ├── ReportView.vue # 报告查看页 +│ │ └── InteractionView.vue # 深度交互页 +│ ├── components/ # 业务组件 +│ │ ├── Step1GraphBuild.vue # Step1 图谱构建组件 +│ │ ├── Step2EnvSetup.vue # Step2 环境搭建组件 +│ │ ├── Step3Simulation.vue # Step3 模拟控制组件 +│ │ ├── Step4Report.vue # Step4 报告生成组件 +│ │ ├── Step5Interaction.vue # Step5 深度交互组件 +│ │ ├── GraphPanel.vue # 图谱数据展示面板 +│ │ └── HistoryDatabase.vue # 历史数据/记忆展示组件 +│ └── assets/logo/ # 前端 Logo 资源 +│ ├── MiroFish_logo_left.jpeg +│ └── MiroFish_logo_compressed.jpeg +├── backend/ # Flask 后端工程 +│ ├── run.py # 后端服务启动入口 +│ ├── requirements.txt # Python 依赖清单 +│ ├── pyproject.toml # Python 项目元数据与工具配置 +│ ├── uv.lock # uv 锁定依赖版本 +│ ├── app/ +│ │ ├── __init__.py # Flask 应用工厂与蓝图注册 +│ │ ├── config.py # 后端配置与环境变量读取 +│ │ ├── api/ # API 路由层 +│ │ │ ├── __init__.py # Blueprint 初始化 +│ │ │ ├── graph.py # 图谱构建与图谱管理接口 +│ │ │ ├── simulation.py # 实体读取、模拟创建/运行/控制接口 +│ │ │ └── report.py # 报告生成、查询、下载与问答接口 +│ │ ├── services/ # 核心业务服务层 +│ │ │ ├── graph_builder.py # GraphRAG 图谱构建服务 +│ │ │ ├── ontology_generator.py # 本体/实体类型生成服务 +│ │ │ ├── text_processor.py # 种子文本清洗与预处理 +│ │ │ ├── zep_entity_reader.py # Zep 图谱实体读取与过滤 +│ │ │ ├── oasis_profile_generator.py # OASIS 角色画像生成 +│ │ │ ├── simulation_config_generator.py # 模拟配置自动生成 +│ │ │ ├── simulation_manager.py # 模拟状态机与生命周期管理 +│ │ │ ├── simulation_runner.py # 后台模拟进程执行与监控 +│ │ │ ├── simulation_ipc.py # 模拟进程 IPC 通信协议 +│ │ │ ├── zep_graph_memory_updater.py # 模拟动作回写图谱记忆 +│ │ │ ├── zep_tools.py # ReportAgent 可调用的检索工具集 +│ │ │ └── report_agent.py # ReACT 报告生成与交互问答 +│ │ ├── models/ # 状态模型层 +│ │ │ ├── __init__.py +│ │ │ ├── project.py # 项目状态与元数据管理 +│ │ │ └── task.py # 异步任务状态模型 +│ │ └── utils/ # 通用基础设施 +│ │ ├── __init__.py +│ │ ├── llm_client.py # OpenAI SDK 兼容 LLM 客户端 +│ │ ├── file_parser.py # 上传文件解析与抽取工具 +│ │ ├── logger.py # 分层日志系统 +│ │ ├── retry.py # 通用重试装饰器/逻辑 +│ │ └── zep_paging.py # Zep 分页读取工具 +│ ├── scripts/ # OASIS 执行脚本 +│ │ ├── run_parallel_simulation.py # Twitter + Reddit 并行模拟入口 +│ │ ├── run_twitter_simulation.py # Twitter 模拟执行脚本 +│ │ ├── run_reddit_simulation.py # Reddit 模拟执行脚本 +│ │ ├── action_logger.py # Agent 行为日志采集脚本 +│ │ └── test_profile_format.py # 画像格式校验脚本 +│ ├── uploads/ # 运行时数据目录(项目/模拟/报告产物) +│ └── logs/ # 后端日志输出目录 +├── static/ +│ └── image/ # README 图片与演示资源 +├── package.json # 根目录脚本(联动前后端) +├── docker-compose.yml # Docker 编排(前端+后端) +├── Dockerfile # Docker 镜像构建定义 +├── .env.example # 环境变量示例 +├── README.md # 中文文档 +├── README-EN.md # 英文文档 +└── LICENSE # 开源许可证 +``` + ## 🚀 快速开始 ### 一、源码部署(推荐) @@ -115,23 +222,66 @@ cp .env.example .env **必需的环境变量:** ```env -# LLM API配置(支持 OpenAI SDK 格式的任意 LLM API) +# LLM API配置(支持 OpenAI / Codex-compatible / OpenAI SDK 格式的任意 LLM API) +# 也支持直接使用 OPENAI_API_KEY / OPENAI_BASE_URL / OPENAI_API_BASE_URL / OPENAI_MODEL # 推荐使用阿里百炼平台qwen-plus模型:https://bailian.console.aliyun.com/ # 注意消耗较大,可先进行小于40轮的模拟尝试 LLM_API_KEY=your_api_key LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 LLM_MODEL_NAME=qwen-plus +# 可选:上下文较小的 OpenAI-compatible 模型可降低输出上限 +# LLM_MAX_TOKENS=4096 # Zep Cloud 配置 # 每月免费额度即可支撑简单使用:https://app.getzep.com/ ZEP_API_KEY=your_zep_api_key ``` +说明:后端现在同时识别项目内的 `LLM_*` 配置和标准 `OPENAI_*` 配置,因此可直接接入 OpenAI、Codex 兼容网关、LM Studio、Ollama 等 OpenAI-compatible 服务,不需要额外的 `LLM_PROVIDER` 开关。若同时设置了多个基础地址变量,则按 `LLM_BASE_URL` > `OPENAI_BASE_URL` > `OPENAI_API_BASE_URL` 的优先级生效;当这些值互相冲突时,`/api/graph/config/status` 与 `backend/scripts/print_config_status.py` 会给出明确告警。 + +常见兼容后端示例: + +```env +# OpenAI / Codex-compatible 网关 +OPENAI_API_KEY=your_api_key +OPENAI_BASE_URL=https://api.openai.com/v1 +OPENAI_API_BASE_URL=https://api.openai.com/v1 +OPENAI_MODEL=gpt-4.1-mini + +# 阿里云百炼 Coding Plan +OPENAI_API_KEY=your_dashscope_key +OPENAI_API_BASE_URL=https://coding.dashscope.aliyuncs.com/v1 +OPENAI_MODEL=qwen3.5-plus +``` + +验证是否已按 OpenAI-compatible 方式接入: + +- 先访问 `http://localhost:5001/health`,确认后端进程已启动。 +- 或直接运行 `npm run check:backend-config`,无需启动服务也能打印同样的非敏感 config-status JSON。 +- 如果你想走最直接的本地后端启动路径,可运行 `npm run backend:local`。它会先执行同样的配置预检,只有当前 `LLM_*` / `OPENAI_*` 别名解析正常时才启动 Flask。 +- 再访问 `http://localhost:5001/api/graph/config/status`。返回 JSON 中 `llm.backend_mode` 应为 `openai_compatible`。 +- `summary.llm.sources` 会显示当前实际生效的是 `LLM_*` 还是 `OPENAI_*` 环境变量,以及具体命中了 `OPENAI_BASE_URL` 还是 `OPENAI_API_BASE_URL`,因此可以直接确认 Codex / OpenAI / DashScope Coding Plan 这类兼容网关是否已被正确识别,不需要额外设置 `LLM_PROVIDER`。 +- 同一个 config-status 里的 `summary.capabilities` 还会明确区分“直接 LLM 已就绪”和“哪些步骤仍依赖 Zep”:`direct_llm` 对应直连 Codex / OpenAI-compatible 后端,`graph_build` 与 `graph_report_tools` 对应 Step 1 / Step 4,`existing_simulation_interaction` 则表示只要已有 Step 2/3 产物,就仍可继续 Step 5 互动。 +- 如果这份 config-status 里仍然提示 `ZEP_API_KEY is not configured`,说明直连 LLM 的 `OPENAI_*` / Codex 兼容路径本身是正常的,只是 Step 1 图谱构建目前仍依赖 Zep,尚未落地仓库原生的替代图谱后端。 +- 如果本地验证时没有设置 `SECRET_KEY`,`npm run check:backend-config` 里出现“临时生成 SECRET_KEY”的 warning 是预期行为,并不表示直连 `OPENAI_*` 配置失败。 + +如果遇到 `5001` 根路径返回 `404`,那通常不是后端启动失败,而是因为后端只暴露 API 路由;请改用 `http://localhost:5001/health` 检查健康状态。 + +如果 Step 5 深度互动里对单个角色提问、批量问卷或全局采访经常超时,可以同时调大前端请求超时 `VITE_API_TIMEOUT`(毫秒)以及后端 Interview 等待时间 `INTERVIEW_AGENT_TIMEOUT_SECONDS`、`INTERVIEW_BATCH_TIMEOUT_SECONDS`、`INTERVIEW_ALL_TIMEOUT_SECONDS`(秒)。 + +首次体验建议先选 1 万字以内的 PDF / Markdown / TXT 材料,并把模拟轮次控制在 30 轮左右,先确认图谱构建、环境初始化和健康检查都正常,再逐步放大规模,避免过早耗尽 Zep 免费额度或把问题混在一起排查。 + #### 2. 安装依赖 ```bash -# 一键安装所有依赖(根目录 + 前端 + 后端) +# 推荐的核心安装路径:图谱 / 报告 / OpenAI-compatible 后端 +npm run setup:core + +# 向后兼容别名,效果与 setup:core 相同 npm run setup:all + +# 如需启用 OASIS 仿真运行(Step 3 / Step 5),再额外安装可选仿真依赖 +npm run setup:backend:simulation ``` 或者分步安装: @@ -140,21 +290,58 @@ npm run setup:all # 安装 Node 依赖(根目录 + 前端) npm run setup -# 安装 Python 依赖(后端,自动创建虚拟环境) +# 安装 Python 核心依赖(后端,自动创建虚拟环境) npm run setup:backend + +# 等价的核心组合安装快捷方式 +npm run setup:core + +# 安装 OASIS 仿真运行时可选依赖 +npm run setup:backend:simulation ``` +默认的 `setup:core` / `setup:all` 现在只安装根目录、前端,以及图谱构建、报告生成和 OpenAI 兼容后端所需的核心依赖。Step 3 / Step 5 使用的上游 `oasis` 运行时代码现在直接随仓库 vendoring 到 `backend/oasis`,而可选仿真依赖只保留运行所需的显式包,因此不再通过 `camel-oasis -> unstructured==0.13.7` 这条高风险传递依赖链安装。 + +已知限制:`npm run setup:backend:simulation` 现在会在 Python 3.13+ 且未安装 Rust 时直接失败并给出说明,因为当前 `camel-ai -> tiktoken==0.7.0` 仍会触发源码构建。若只需要核心后端,可继续使用默认安装;若要实际运行 Step 3 / Step 5 仿真,当前更稳妥的是使用 Python 3.11/3.12,或先安装 Rust 再执行该命令。 + #### 3. 启动服务 ```bash # 同时启动前后端(在项目根目录执行) npm run dev + +# 或仅启动后端,并先做一次 OpenAI-compatible 配置预检 +npm run backend:local ``` **服务地址:** - 前端:`http://localhost:3000` - 后端 API:`http://localhost:5001` +默认前后端双端口部署时,前端会自动访问同主机的 `5001` 端口后端。后端根路径仅提供 API,快速健康检查请访问 `http://localhost:5001/health`。 + +#### 3.1 常见问题 + +**支持哪些模型 / API?** + +- 当前后端支持任意 OpenAI-compatible 接口,不要求必须使用某一个固定厂商。 +- 已在本仓库中验证并写入示例配置的路径包括:OpenAI、Codex 兼容网关、阿里云百炼兼容模式、阿里云百炼 Coding Plan,以及 LM Studio / Ollama 这类 OpenAI SDK 兼容本地网关。 +- 配置时既可以使用项目内的 `LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL_NAME`,也可以直接使用标准 `OPENAI_API_KEY` / `OPENAI_API_BASE_URL` / `OPENAI_MODEL`。 + +**浏览器刷新、关闭页面后会发生什么?** + +- 单纯刷新页面或暂时关闭浏览器,不会直接终止服务器端已经启动的图谱构建、模拟或报告任务。 +- 已落盘的数据会保存在 `backend/uploads/` 下,首页历史记录也可以重新打开 Step1「图谱构建」、Step2「环境搭建」和 Step4「分析报告」。 +- 但 Step3「开始模拟」和 Step5「深度互动」依赖实时运行中的 OASIS 环境;如果后端进程、容器或对应模拟环境已经关闭,就不能像录像回放一样无缝恢复,需要重新准备或重新启动该运行环境。 + +**之后怎么验证某次预测是否被现实结果印证?** + +- 当前仓库还没有“自动抓取真实世界结果并给出准确率分数”的内置评测器,但已经能保留一套稳定的人工复核证据链。 +- 先在 Step4 记录 `report_id`,并用首页历史记录保留对应的 `simulation_id`;这两个 ID 会把后续复核绑定到同一轮图谱、环境和报告产物。 +- 需要离线留档时,可直接在 Step4 导出 Markdown,或到 `backend/uploads/reports//full_report.md` 与同目录 JSON 中保存当时的完整报告内容。 +- 等现实事件有后续结果后,再从首页历史重新打开 Step4,对照报告里的关键判断、时间线和条件假设逐条比对;如果需要补充上下文,也可以同时回看 Step1 / Step2 的输入材料。 +- 如果对应模拟环境仍在线,可继续用 Step5 向 Report Agent 或角色追问“当初哪些判断已经兑现、哪些前提没有发生”;如果运行环境已关闭,当前版本仍以 Step4 留档 + 人工比对为主。 + **单独启动:** ```bash @@ -162,19 +349,41 @@ npm run backend # 仅启动后端 npm run frontend # 仅启动前端 ``` +#### 4. 轻量后端校验 + +如果 `uv sync` 或 `uv run pytest` 因 `tiktoken` 等重依赖需要 Rust 工具链而受阻,可先运行仓库内置的快速后端校验路径: + +```bash +npm run test:backend:lite +``` + +该路径会按需创建 `.tmp-test-venv/`,只安装当前低风险回归测试所需的最小依赖,并执行 `test_llm_client.py` 与 `test_graph_builder.py`。 + ### 二、Docker 部署 ```bash # 1. 配置环境变量(同源码部署) cp .env.example .env -# 2. 拉取镜像并启动 +# 2. 可选:如果 GHCR 拉取较慢或失败,可先覆盖镜像地址 +# MIROFISH_IMAGE=ghcr.nju.edu.cn/666ghj/mirofish:latest + +# 3. 拉取镜像并启动 docker compose up -d ``` 默认会读取根目录下的 `.env`,并映射端口 `3000(前端)/5001(后端)` -> 在 `docker-compose.yml` 中已通过注释提供加速镜像地址,可按需替换 +如果前后端部署在不同主机或不同端口,请为前端显式设置 `VITE_API_BASE_URL`。 +也可以在前端首页或 Step 1 / Step 2 工作台右上角打开 `后端 API` 面板,直接输入运行中的后端地址并持久化到浏览器本地,无需重新构建前端。 + +如果你是前后端跨域部署,并且希望限制允许访问 `/api/*` 的前端来源,也可以设置后端环境变量 `CORS_ALLOWED_ORIGINS`(逗号分隔),并按需补充 `CORS_ALLOW_METHODS` / `CORS_ALLOW_HEADERS`。为兼容现有部署,默认行为仍然是允许所有来源 `*`。 + +`docker-compose.yml` 现在会读取 `MIROFISH_IMAGE`,因此可以直接通过 `.env` 或单次命令切换到镜像源/私有仓库,无需手动修改 compose 文件。 + +```bash +MIROFISH_IMAGE=ghcr.nju.edu.cn/666ghj/mirofish:latest docker compose up -d +``` ## 📬 更多交流 diff --git a/backend/app/__init__.py b/backend/app/__init__.py index aba624bb..62ef2022 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -13,6 +13,7 @@ from flask_cors import CORS from .config import Config +from .i18n import get_locale, tr from .utils.logger import setup_logger, get_logger @@ -33,33 +34,39 @@ def create_app(config_class=Config): is_reloader_process = os.environ.get('WERKZEUG_RUN_MAIN') == 'true' debug_mode = app.config.get('DEBUG', False) should_log_startup = not debug_mode or is_reloader_process + startup_locale = get_locale(os.environ.get("MIROFISH_LOCALE")) if should_log_startup: logger.info("=" * 50) - logger.info("MiroFish Backend 启动中...") + logger.info(tr("app.starting", startup_locale)) logger.info("=" * 50) # 启用CORS - CORS(app, resources={r"/api/*": {"origins": "*"}}) + CORS(app, resources={r"/api/*": config_class.get_cors_resources()}) # 注册模拟进程清理函数(确保服务器关闭时终止所有模拟进程) from .services.simulation_runner import SimulationRunner SimulationRunner.register_cleanup() if should_log_startup: - logger.info("已注册模拟进程清理函数") + logger.info(tr("app.cleanup_registered", startup_locale)) # 请求日志中间件 @app.before_request def log_request(): logger = get_logger('mirofish.request') - logger.debug(f"请求: {request.method} {request.path}") + logger.debug(tr("app.request", method=request.method, path=request.path)) if request.content_type and 'json' in request.content_type: - logger.debug(f"请求体: {request.get_json(silent=True)}") + logger.debug( + tr( + "app.request_body", + body=request.get_json(silent=True), + ) + ) @app.after_request def log_response(response): logger = get_logger('mirofish.request') - logger.debug(f"响应: {response.status_code}") + logger.debug(tr("app.response", status_code=response.status_code)) return response # 注册蓝图 @@ -68,13 +75,32 @@ def log_response(response): app.register_blueprint(simulation_bp, url_prefix='/api/simulation') app.register_blueprint(report_bp, url_prefix='/api/report') + def backend_status_payload(): + return { + 'status': 'ok', + 'service': 'MiroFish Backend', + 'api_prefixes': [ + '/api/graph', + '/api/simulation', + '/api/report', + ], + 'health_endpoint': '/health', + } + + @app.route('/') + def index(): + return backend_status_payload() + # 健康检查 @app.route('/health') def health(): - return {'status': 'ok', 'service': 'MiroFish Backend'} - + return backend_status_payload() + + @app.route('/healthz') + def healthz(): + return backend_status_payload() + if should_log_startup: - logger.info("MiroFish Backend 启动完成") + logger.info(tr("app.started", startup_locale)) return app - diff --git a/backend/app/api/graph.py b/backend/app/api/graph.py index 12ff1ba2..3462e5a8 100644 --- a/backend/app/api/graph.py +++ b/backend/app/api/graph.py @@ -4,16 +4,19 @@ """ import os +import re import traceback import threading from flask import request, jsonify from . import graph_bp from ..config import Config +from ..i18n import get_locale, tr from ..services.ontology_generator import OntologyGenerator from ..services.graph_builder import GraphBuilderService from ..services.text_processor import TextProcessor from ..utils.file_parser import FileParser +from ..utils.error_handler import handle_api_exception from ..utils.logger import get_logger from ..models.task import TaskManager, TaskStatus from ..models.project import ProjectManager, ProjectStatus @@ -21,6 +24,45 @@ # 获取日志器 logger = get_logger('mirofish.api') +GRAPH_TASK_MESSAGE_MAP = { + "初始化图谱构建服务...": "graph.build_service_initializing", + "开始构建图谱...": "graph.build_started_worker", + "文本分块中...": "graph.build_chunking", + "创建Zep图谱...": "graph.build_creating_graph", + "设置本体定义...": "graph.build_setting_ontology", + "本体已设置": "graph.build_ontology_set", + "等待Zep处理数据...": "graph.build_waiting_for_zep", + "获取图谱数据...": "graph.build_fetching_graph_data", + "获取图谱信息...": "graph.build_fetching_graph_info", + "图谱构建完成": "graph.build_completed", +} + +GRAPH_ERROR_CONTEXTS_EN = { + "生成本体失败": "Failed to generate the ontology", + "启动图谱构建失败": "Failed to start the graph build", + "获取图谱数据失败": "Failed to fetch graph data", + "删除图谱失败": "Failed to delete the graph", +} + + +def _graph_task_type(locale: str, graph_name: str) -> str: + return tr("graph.build_task_type", locale, graph_name=graph_name) + + +def _translate_graph_task_type(locale: str, task_type: str | None) -> str | None: + if not task_type: + return task_type + + zh_match = re.match(r"^构建图谱: (?P.+)$", task_type) + if zh_match: + return _graph_task_type(locale, zh_match.group("graph_name")) + + en_match = re.match(r"^Build graph: (?P.+)$", task_type) + if en_match: + return _graph_task_type(locale, en_match.group("graph_name")) + + return task_type + def allowed_file(filename: str) -> bool: """检查文件扩展名是否允许""" @@ -30,6 +72,166 @@ def allowed_file(filename: str) -> bool: return ext in Config.ALLOWED_EXTENSIONS +def _document_error_entry( + *, + locale: str, + filename: str, + code: str, + message_key: str, + details: str | None = None, +) -> dict[str, object]: + entry: dict[str, object] = { + "filename": filename, + "code": code, + "message": tr( + message_key, + locale, + filename=filename, + details=details or "", + extensions=", ".join(sorted(Config.ALLOWED_EXTENSIONS)), + ), + } + if details: + entry["details"] = details + if code == "unsupported_file_type": + entry["supported_extensions"] = sorted(Config.ALLOWED_EXTENSIONS) + return entry + + +def _backend_config_error_response(locale: str): + """Return a consistent non-sensitive config error payload for graph endpoints.""" + validation = Config.validate_comprehensive(locale=locale) + if validation.is_valid: + return None + + return jsonify({ + "success": False, + "error": tr("api.backend_config_incomplete", locale, details="; ".join(validation.errors)), + "data": { + "validation": validation.to_dict(), + "summary": Config.get_config_summary(), + } + }), 503 + + +def _translate_graph_task_message(locale: str, message: str | None) -> str | None: + if locale != "en" or not message: + return message + + message_key = GRAPH_TASK_MESSAGE_MAP.get(message) + if message_key: + return tr(message_key, locale) + + add_chunks_match = re.match(r"^开始添加 (?P\d+) 个文本块\.\.\.$", message) + if add_chunks_match: + return tr("graph.build_add_batches_start", locale, total_chunks=add_chunks_match.group("count")) + + batch_sending_match = re.match( + r"^发送第 (?P\d+)/(?P\d+) 批数据 \((?P\d+) 块\)\.\.\.$", + message, + ) + if batch_sending_match: + return tr( + "graph.build_batch_sending", + locale, + batch_num=batch_sending_match.group("batch_num"), + total_batches=batch_sending_match.group("total_batches"), + chunk_count=batch_sending_match.group("chunk_count"), + ) + + batch_retry_match = re.match( + r"^批次 (?P\d+) 发送失败,(?P\d+(?:\.\d+)?)秒后重试 \((?P\d+)/(?P\d+)\)\.\.\.$", + message, + ) + if batch_retry_match: + return tr( + "graph.build_batch_retry", + locale, + batch_num=batch_retry_match.group("batch_num"), + wait_time=float(batch_retry_match.group("wait_time")), + attempt=batch_retry_match.group("attempt"), + total=batch_retry_match.group("total"), + ) + + graph_created_match = re.match(r"^图谱已创建: (?P.+)$", message) + if graph_created_match: + return tr("graph.build_graph_created", locale, graph_id=graph_created_match.group("graph_id")) + + chunks_split_match = re.match(r"^文本已分割为 (?P\d+) 个块$", message) + if chunks_split_match: + return tr("graph.build_chunks_split", locale, total_chunks=chunks_split_match.group("count")) + + wait_none_match = re.match(r"^无需等待(没有 episode)$", message) + if wait_none_match: + return tr("graph.build_wait_not_required", locale) + + wait_started_match = re.match(r"^开始等待 (?P\d+) 个文本块处理\.\.\.$", message) + if wait_started_match: + return tr("graph.build_wait_started", locale, total_episodes=wait_started_match.group("count")) + + wait_timeout_match = re.match(r"^部分文本块超时,已完成 (?P\d+)/(?P\d+)$", message) + if wait_timeout_match: + return tr( + "graph.build_wait_partial_timeout", + locale, + completed_count=wait_timeout_match.group("completed"), + total_episodes=wait_timeout_match.group("total"), + ) + + wait_progress_match = re.match( + r"^Zep处理中\.\.\. (?P\d+)/(?P\d+) 完成, (?P\d+) 待处理 \((?P\d+)秒\)$", + message, + ) + if wait_progress_match: + return tr( + "graph.build_wait_progress", + locale, + completed_count=wait_progress_match.group("completed"), + total_episodes=wait_progress_match.group("total"), + pending_count=wait_progress_match.group("pending"), + elapsed=wait_progress_match.group("elapsed"), + ) + + wait_completed_match = re.match(r"^处理完成: (?P\d+)/(?P\d+)$", message) + if wait_completed_match: + return tr( + "graph.build_wait_completed", + locale, + completed_count=wait_completed_match.group("completed"), + total_episodes=wait_completed_match.group("total"), + ) + + failed_match = re.match(r"^构建失败: (?P
.+)$", message) + if failed_match: + return tr("graph.build_failed", locale, details=failed_match.group("details")) + + return message + + +def _translate_graph_task_payload(locale: str, payload: dict | None) -> dict | None: + if not payload: + return payload + + translated = dict(payload) + translated["task_type"] = _translate_graph_task_type(locale, payload.get("task_type")) + translated["message"] = _translate_graph_task_message(locale, payload.get("message")) + return translated + + +def _graph_error_context(locale: str, context: str) -> str: + if locale == "en": + return GRAPH_ERROR_CONTEXTS_EN.get(context, context) + return context + + +def _handle_graph_api_exception(error: Exception, locale: str, context: str): + return handle_api_exception(logger, error, _graph_error_context(locale, context)) + + +def _log_graph_message(logger_obj, level: str, locale: str, key: str, **kwargs) -> None: + getattr(logger_obj, level)(tr(key, locale, **kwargs)) + + # ============== 项目管理接口 ============== @graph_bp.route('/project/', methods=['GET']) @@ -42,7 +244,7 @@ def get_project(project_id: str): if not project: return jsonify({ "success": False, - "error": f"项目不存在: {project_id}" + "error": tr("graph.project_not_found", get_locale(), project_id=project_id) }), 404 return jsonify({ @@ -76,12 +278,12 @@ def delete_project(project_id: str): if not success: return jsonify({ "success": False, - "error": f"项目不存在或删除失败: {project_id}" + "error": tr("graph.project_delete_failed", get_locale(), project_id=project_id) }), 404 return jsonify({ "success": True, - "message": f"项目已删除: {project_id}" + "message": tr("graph.project_deleted", get_locale(), project_id=project_id) }) @@ -95,7 +297,7 @@ def reset_project(project_id: str): if not project: return jsonify({ "success": False, - "error": f"项目不存在: {project_id}" + "error": tr("graph.project_not_found", get_locale(), project_id=project_id) }), 404 # 重置到本体已生成状态 @@ -111,11 +313,29 @@ def reset_project(project_id: str): return jsonify({ "success": True, - "message": f"项目已重置: {project_id}", + "message": tr("graph.project_reset", get_locale(), project_id=project_id), "data": project.to_dict() }) +@graph_bp.route('/config/status', methods=['GET']) +def get_backend_config_status(): + """Return non-sensitive backend configuration status for frontend diagnostics.""" + locale = get_locale() + config_error = _backend_config_error_response(locale) + if config_error is not None: + return config_error + + validation = Config.validate_comprehensive(locale=locale) + return jsonify({ + "success": True, + "data": { + "validation": validation.to_dict(), + "summary": Config.get_config_summary(), + } + }) + + # ============== 接口1:上传文件并生成本体 ============== @graph_bp.route('/ontology/generate', methods=['POST']) @@ -147,20 +367,31 @@ def generate_ontology(): } """ try: - logger.info("=== 开始生成本体定义 ===") + locale = get_locale() + _log_graph_message(logger, "info", locale, "graph.ontology_log_started") + + config_error = _backend_config_error_response(locale) + if config_error is not None: + return config_error # 获取参数 simulation_requirement = request.form.get('simulation_requirement', '') project_name = request.form.get('project_name', 'Unnamed Project') additional_context = request.form.get('additional_context', '') - logger.debug(f"项目名称: {project_name}") - logger.debug(f"模拟需求: {simulation_requirement[:100]}...") + _log_graph_message(logger, "debug", locale, "graph.ontology_log_project_name", project_name=project_name) + _log_graph_message( + logger, + "debug", + locale, + "graph.ontology_log_requirement", + requirement=f"{simulation_requirement[:100]}...", + ) if not simulation_requirement: return jsonify({ "success": False, - "error": "请提供模拟需求描述 (simulation_requirement)" + "error": tr("graph.simulation_requirement_required", locale) }), 400 # 获取上传的文件 @@ -168,52 +399,113 @@ def generate_ontology(): if not uploaded_files or all(not f.filename for f in uploaded_files): return jsonify({ "success": False, - "error": "请至少上传一个文档文件" + "error": tr("graph.upload_files_required", locale) }), 400 # 创建项目 project = ProjectManager.create_project(name=project_name) project.simulation_requirement = simulation_requirement - logger.info(f"创建项目: {project.project_id}") + _log_graph_message(logger, "info", locale, "graph.project_created_log", project_id=project.project_id) # 保存文件并提取文本 document_texts = [] all_text = "" - + file_errors = [] + for file in uploaded_files: - if file and file.filename and allowed_file(file.filename): - # 保存文件到项目目录 - file_info = ProjectManager.save_file_to_project( - project.project_id, - file, - file.filename + if not file or not file.filename: + continue + + if not allowed_file(file.filename): + file_errors.append( + _document_error_entry( + locale=locale, + filename=file.filename, + code="unsupported_file_type", + message_key="graph.unsupported_file_type", + ) ) - project.files.append({ - "filename": file_info["original_filename"], - "size": file_info["size"] - }) - - # 提取文本 + continue + + # 保存文件到项目目录 + file_info = ProjectManager.save_file_to_project( + project.project_id, + file, + file.filename + ) + project.files.append({ + "filename": file_info["original_filename"], + "size": file_info["size"] + }) + + try: text = FileParser.extract_text(file_info["path"]) - text = TextProcessor.preprocess_text(text) - document_texts.append(text) - all_text += f"\n\n=== {file_info['original_filename']} ===\n{text}" - + except Exception as exc: + _log_graph_message( + logger, + "warning", + locale, + "graph.document_parse_failed_log", + filename=file_info["original_filename"], + details=str(exc), + ) + file_errors.append( + _document_error_entry( + locale=locale, + filename=file_info["original_filename"], + code="document_parse_failed", + message_key="graph.document_parse_failed", + details=str(exc), + ) + ) + continue + + text = TextProcessor.preprocess_text(text) + if not text: + file_errors.append( + _document_error_entry( + locale=locale, + filename=file_info["original_filename"], + code="document_empty_after_parse", + message_key="graph.document_empty_after_parse", + ) + ) + continue + + document_texts.append(text) + all_text += f"\n\n=== {file_info['original_filename']} ===\n{text}" + + if file_errors: + ProjectManager.delete_project(project.project_id) + return jsonify({ + "success": False, + "error": tr("graph.document_processing_failed", locale, count=len(file_errors)), + "data": { + "file_errors": file_errors, + } + }), 400 + if not document_texts: ProjectManager.delete_project(project.project_id) return jsonify({ "success": False, - "error": "没有成功处理任何文档,请检查文件格式" + "error": tr("graph.no_processed_documents", locale) }), 400 # 保存提取的文本 project.total_text_length = len(all_text) ProjectManager.save_extracted_text(project.project_id, all_text) - logger.info(f"文本提取完成,共 {len(all_text)} 字符") + _log_graph_message( + logger, + "info", + locale, + "graph.text_extraction_completed_log", + total_chars=len(all_text), + ) # 生成本体 - logger.info("调用 LLM 生成本体定义...") - generator = OntologyGenerator() + _log_graph_message(logger, "info", locale, "graph.ontology_call_started_log") + generator = OntologyGenerator(locale=locale) ontology = generator.generate( document_texts=document_texts, simulation_requirement=simulation_requirement, @@ -223,7 +515,14 @@ def generate_ontology(): # 保存本体到项目 entity_count = len(ontology.get("entity_types", [])) edge_count = len(ontology.get("edge_types", [])) - logger.info(f"本体生成完成: {entity_count} 个实体类型, {edge_count} 个关系类型") + _log_graph_message( + logger, + "info", + locale, + "graph.ontology_generation_completed_log", + entity_count=entity_count, + edge_count=edge_count, + ) project.ontology = { "entity_types": ontology.get("entity_types", []), @@ -232,7 +531,13 @@ def generate_ontology(): project.analysis_summary = ontology.get("analysis_summary", "") project.status = ProjectStatus.ONTOLOGY_GENERATED ProjectManager.save_project(project) - logger.info(f"=== 本体生成完成 === 项目ID: {project.project_id}") + _log_graph_message( + logger, + "info", + locale, + "graph.ontology_log_completed", + project_id=project.project_id, + ) return jsonify({ "success": True, @@ -247,11 +552,7 @@ def generate_ontology(): }) except Exception as e: - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_graph_api_exception(e, get_locale(), "生成本体失败") # ============== 接口2:构建图谱 ============== @@ -280,28 +581,28 @@ def build_graph(): } """ try: - logger.info("=== 开始构建图谱 ===") - - # 检查配置 - errors = [] - if not Config.ZEP_API_KEY: - errors.append("ZEP_API_KEY未配置") - if errors: - logger.error(f"配置错误: {errors}") - return jsonify({ - "success": False, - "error": "配置错误: " + "; ".join(errors) - }), 500 + locale = get_locale() + _log_graph_message(logger, "info", locale, "graph.build_log_started") + + config_error = _backend_config_error_response(locale) + if config_error is not None: + return config_error # 解析请求 data = request.get_json() or {} project_id = data.get('project_id') - logger.debug(f"请求参数: project_id={project_id}") + _log_graph_message( + logger, + "debug", + locale, + "graph.build_log_request_params", + project_id=project_id, + ) if not project_id: return jsonify({ "success": False, - "error": "请提供 project_id" + "error": tr("graph.project_id_required", locale) }), 400 # 获取项目 @@ -309,7 +610,7 @@ def build_graph(): if not project: return jsonify({ "success": False, - "error": f"项目不存在: {project_id}" + "error": tr("graph.project_not_found", locale, project_id=project_id) }), 404 # 检查项目状态 @@ -318,13 +619,13 @@ def build_graph(): if project.status == ProjectStatus.CREATED: return jsonify({ "success": False, - "error": "项目尚未生成本体,请先调用 /ontology/generate" + "error": tr("graph.ontology_required", locale) }), 400 if project.status == ProjectStatus.GRAPH_BUILDING and not force: return jsonify({ "success": False, - "error": "图谱正在构建中,请勿重复提交。如需强制重建,请添加 force: true", + "error": tr("graph.build_in_progress", locale), "task_id": project.graph_build_task_id }), 400 @@ -349,7 +650,7 @@ def build_graph(): if not text: return jsonify({ "success": False, - "error": "未找到提取的文本内容" + "error": tr("graph.extracted_text_missing", locale) }), 400 # 获取本体 @@ -357,13 +658,20 @@ def build_graph(): if not ontology: return jsonify({ "success": False, - "error": "未找到本体定义" + "error": tr("graph.ontology_missing", locale) }), 400 # 创建异步任务 task_manager = TaskManager() - task_id = task_manager.create_task(f"构建图谱: {graph_name}") - logger.info(f"创建图谱构建任务: task_id={task_id}, project_id={project_id}") + task_id = task_manager.create_task(_graph_task_type(locale, graph_name)) + _log_graph_message( + logger, + "info", + locale, + "graph.build_task_created_log", + task_id=task_id, + project_id=project_id, + ) # 更新项目状态 project.status = ProjectStatus.GRAPH_BUILDING @@ -374,20 +682,26 @@ def build_graph(): def build_task(): build_logger = get_logger('mirofish.build') try: - build_logger.info(f"[{task_id}] 开始构建图谱...") + _log_graph_message( + build_logger, + "info", + locale, + "graph.build_worker_started_log", + task_id=task_id, + ) task_manager.update_task( task_id, status=TaskStatus.PROCESSING, - message="初始化图谱构建服务..." + message=tr("graph.build_service_initializing", locale) ) # 创建图谱构建服务 - builder = GraphBuilderService(api_key=Config.ZEP_API_KEY) + builder = GraphBuilderService(api_key=Config.ZEP_API_KEY, locale=locale) # 分块 task_manager.update_task( task_id, - message="文本分块中...", + message=tr("graph.build_chunking", locale), progress=5 ) chunks = TextProcessor.split_text( @@ -400,7 +714,7 @@ def build_task(): # 创建图谱 task_manager.update_task( task_id, - message="创建Zep图谱...", + message=tr("graph.build_creating_graph", locale), progress=10 ) graph_id = builder.create_graph(name=graph_name) @@ -412,7 +726,7 @@ def build_task(): # 设置本体 task_manager.update_task( task_id, - message="设置本体定义...", + message=tr("graph.build_setting_ontology", locale), progress=15 ) builder.set_ontology(graph_id, ontology) @@ -428,7 +742,7 @@ def add_progress_callback(msg, progress_ratio): task_manager.update_task( task_id, - message=f"开始添加 {total_chunks} 个文本块...", + message=tr("graph.build_add_batches_start", locale, total_chunks=total_chunks), progress=15 ) @@ -442,7 +756,7 @@ def add_progress_callback(msg, progress_ratio): # 等待Zep处理完成(查询每个episode的processed状态) task_manager.update_task( task_id, - message="等待Zep处理数据...", + message=tr("graph.build_waiting_for_zep", locale), progress=55 ) @@ -459,7 +773,7 @@ def wait_progress_callback(msg, progress_ratio): # 获取图谱数据 task_manager.update_task( task_id, - message="获取图谱数据...", + message=tr("graph.build_fetching_graph_data", locale), progress=95 ) graph_data = builder.get_graph_data(graph_id) @@ -470,13 +784,22 @@ def wait_progress_callback(msg, progress_ratio): node_count = graph_data.get("node_count", 0) edge_count = graph_data.get("edge_count", 0) - build_logger.info(f"[{task_id}] 图谱构建完成: graph_id={graph_id}, 节点={node_count}, 边={edge_count}") + _log_graph_message( + build_logger, + "info", + locale, + "graph.build_worker_completed_log", + task_id=task_id, + graph_id=graph_id, + node_count=node_count, + edge_count=edge_count, + ) # 完成 task_manager.update_task( task_id, status=TaskStatus.COMPLETED, - message="图谱构建完成", + message=tr("graph.build_completed", locale), progress=100, result={ "project_id": project_id, @@ -489,18 +812,26 @@ def wait_progress_callback(msg, progress_ratio): except Exception as e: # 更新项目状态为失败 - build_logger.error(f"[{task_id}] 图谱构建失败: {str(e)}") + user_error = builder.format_user_facing_error(e) if 'builder' in locals() else str(e) + _log_graph_message( + build_logger, + "error", + locale, + "graph.build_worker_failed_log", + task_id=task_id, + details=user_error, + ) build_logger.debug(traceback.format_exc()) project.status = ProjectStatus.FAILED - project.error = str(e) + project.error = user_error ProjectManager.save_project(project) task_manager.update_task( task_id, status=TaskStatus.FAILED, - message=f"构建失败: {str(e)}", - error=traceback.format_exc() + message=tr("graph.build_failed", locale, details=user_error), + error=user_error ) # 启动后台线程 @@ -512,16 +843,12 @@ def wait_progress_callback(msg, progress_ratio): "data": { "project_id": project_id, "task_id": task_id, - "message": "图谱构建任务已启动,请通过 /task/{task_id} 查询进度" + "message": tr("graph.build_started", locale, task_id=task_id) } }) except Exception as e: - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_graph_api_exception(e, get_locale(), "启动图谱构建失败") # ============== 任务查询接口 ============== @@ -536,12 +863,12 @@ def get_task(task_id: str): if not task: return jsonify({ "success": False, - "error": f"任务不存在: {task_id}" + "error": tr("graph.task_not_found", get_locale(), task_id=task_id) }), 404 return jsonify({ "success": True, - "data": task.to_dict() + "data": _translate_graph_task_payload(get_locale(), task.to_dict()) }) @@ -554,7 +881,7 @@ def list_tasks(): return jsonify({ "success": True, - "data": [t.to_dict() for t in tasks], + "data": [_translate_graph_task_payload(get_locale(), t) for t in tasks], "count": len(tasks) }) @@ -570,7 +897,7 @@ def get_graph_data(graph_id: str): if not Config.ZEP_API_KEY: return jsonify({ "success": False, - "error": "ZEP_API_KEY未配置" + "error": tr("graph.zep_key_missing", get_locale()) }), 500 builder = GraphBuilderService(api_key=Config.ZEP_API_KEY) @@ -582,11 +909,7 @@ def get_graph_data(graph_id: str): }) except Exception as e: - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_graph_api_exception(e, get_locale(), "获取图谱数据失败") @graph_bp.route('/delete/', methods=['DELETE']) @@ -598,7 +921,7 @@ def delete_graph(graph_id: str): if not Config.ZEP_API_KEY: return jsonify({ "success": False, - "error": "ZEP_API_KEY未配置" + "error": tr("graph.zep_key_missing", get_locale()) }), 500 builder = GraphBuilderService(api_key=Config.ZEP_API_KEY) @@ -606,12 +929,8 @@ def delete_graph(graph_id: str): return jsonify({ "success": True, - "message": f"图谱已删除: {graph_id}" + "message": tr("graph.graph_deleted", get_locale(), graph_id=graph_id) }) except Exception as e: - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_graph_api_exception(e, get_locale(), "删除图谱失败") diff --git a/backend/app/api/report.py b/backend/app/api/report.py index e05c73c3..0d8a0b73 100644 --- a/backend/app/api/report.py +++ b/backend/app/api/report.py @@ -4,20 +4,126 @@ """ import os -import traceback +import re import threading from flask import request, jsonify, send_file from . import report_bp from ..config import Config +from ..i18n import get_locale, tr from ..services.report_agent import ReportAgent, ReportManager, ReportStatus from ..services.simulation_manager import SimulationManager from ..models.project import ProjectManager from ..models.task import TaskManager, TaskStatus +from ..utils.error_handler import handle_api_exception from ..utils.logger import get_logger logger = get_logger('mirofish.api.report') +REPORT_STAGE_LABELS = { + "pending": "Pending", + "planning": "Planning", + "generating": "Generating", + "completed": "Completed", + "failed": "Failed", +} + +REPORT_PROGRESS_MESSAGE_MAP = { + "初始化Report Agent...": "Initializing Report Agent...", + "初始化报告...": "Initializing report...", + "开始规划报告大纲...": "Starting report outline planning...", + "正在分析模拟需求...": "Analyzing the simulation requirement...", + "正在生成报告大纲...": "Generating the report outline...", + "正在解析大纲结构...": "Parsing the outline structure...", + "大纲规划完成": "Outline planning completed", + "正在组装完整报告...": "Assembling the full report...", + "报告生成完成": "Report generation completed", +} + + +def _report_error_context(locale: str, key: str) -> str: + return tr(key, locale) + + +def _handle_report_api_exception(error: Exception, locale: str, key: str): + return handle_api_exception(logger, error, _report_error_context(locale, key)) + + +def _report_backend_config_error_response(locale: str): + """Return a consistent non-sensitive config error payload for report endpoints.""" + validation = Config.validate_comprehensive(locale=locale) + if validation.is_valid: + return None + + return jsonify({ + "success": False, + "error": tr("api.backend_config_incomplete", locale, details="; ".join(validation.errors)), + "data": { + "validation": validation.to_dict(), + "summary": Config.get_config_summary(), + } + }), 503 + + +def _translate_report_message(locale: str, message: str | None) -> str | None: + if locale != "en" or not message: + return message + + translated = REPORT_PROGRESS_MESSAGE_MAP.get(message, message) + + stage_match = re.match(r"^\[(?P[a-z_]+)\]\s+(?P.+)$", translated) + if stage_match: + stage = stage_match.group("stage") + body = _translate_report_message(locale, stage_match.group("body")) or "" + return f"[{REPORT_STAGE_LABELS.get(stage, stage)}] {body}" + + outline_match = re.match(r"^大纲规划完成,共(?P\d+)个章节$", translated) + if outline_match: + return f"Outline planning completed with {outline_match.group('count')} sections" + + generating_match = re.match( + r"^正在生成章节: (?P.+) \((?P<index>\d+)/(?P<total>\d+)\)$", + translated, + ) + if generating_match: + return ( + f"Generating section: {generating_match.group('title')} " + f"({generating_match.group('index')}/{generating_match.group('total')})" + ) + + completed_match = re.match(r"^章节 (?P<title>.+) 已完成$", translated) + if completed_match: + return f"Section completed: {completed_match.group('title')}" + + return translated + + +def _translate_report_progress_payload(locale: str, payload: dict | None) -> dict | None: + if locale != "en" or not payload: + return payload + + translated_payload = dict(payload) + translated_payload["message"] = _translate_report_message(locale, payload.get("message")) + return translated_payload + + +def _sanitize_download_name_part(value: str | None) -> str: + sanitized = re.sub(r"[^a-zA-Z0-9._-]+", "-", (value or "").strip()) + sanitized = re.sub(r"-+", "-", sanitized) + return sanitized.strip("-") + + +def _build_report_download_name(report) -> str: + report_part = _sanitize_download_name_part(getattr(report, "report_id", "")) + simulation_part = _sanitize_download_name_part(getattr(report, "simulation_id", "")) + + if not report_part: + report_part = "report" + + if simulation_part: + return f"mirofish-report-{report_part}--simulation-{simulation_part}.md" + return f"mirofish-report-{report_part}.md" + # ============== 报告生成接口 ============== @@ -46,6 +152,7 @@ def generate_report(): } } """ + locale = get_locale() try: data = request.get_json() or {} @@ -53,7 +160,7 @@ def generate_report(): if not simulation_id: return jsonify({ "success": False, - "error": "请提供 simulation_id" + "error": tr("report.simulation_id_required", locale) }), 400 force_regenerate = data.get('force_regenerate', False) @@ -65,7 +172,7 @@ def generate_report(): if not state: return jsonify({ "success": False, - "error": f"模拟不存在: {simulation_id}" + "error": tr("simulation.not_found", locale, simulation_id=simulation_id) }), 404 # 检查是否已有报告 @@ -78,31 +185,35 @@ def generate_report(): "simulation_id": simulation_id, "report_id": existing_report.report_id, "status": "completed", - "message": "报告已存在", + "message": tr("report.already_exists", locale), "already_generated": True } }) + + config_error = _report_backend_config_error_response(locale) + if config_error is not None: + return config_error # 获取项目信息 project = ProjectManager.get_project(state.project_id) if not project: return jsonify({ "success": False, - "error": f"项目不存在: {state.project_id}" + "error": tr("report.project_not_found", locale, project_id=state.project_id) }), 404 graph_id = state.graph_id or project.graph_id if not graph_id: return jsonify({ "success": False, - "error": "缺少图谱ID,请确保已构建图谱" + "error": tr("report.graph_id_required_built", locale) }), 400 simulation_requirement = project.simulation_requirement if not simulation_requirement: return jsonify({ "success": False, - "error": "缺少模拟需求描述" + "error": tr("report.requirement_missing", locale) }), 400 # 提前生成 report_id,以便立即返回给前端 @@ -127,14 +238,15 @@ def run_generate(): task_id, status=TaskStatus.PROCESSING, progress=0, - message="初始化Report Agent..." + message=_translate_report_message(locale, "初始化Report Agent...") ) # 创建Report Agent agent = ReportAgent( graph_id=graph_id, simulation_id=simulation_id, - simulation_requirement=simulation_requirement + simulation_requirement=simulation_requirement, + locale=locale, ) # 进度回调 @@ -142,7 +254,7 @@ def progress_callback(stage, progress, message): task_manager.update_task( task_id, progress=progress, - message=f"[{stage}] {message}" + message=_translate_report_message(locale, f"[{stage}] {message}") ) # 生成报告(传入预先生成的 report_id) @@ -161,14 +273,19 @@ def progress_callback(stage, progress, message): "report_id": report.report_id, "simulation_id": simulation_id, "status": "completed" - } + }, + locale=locale, ) else: - task_manager.fail_task(task_id, report.error or "报告生成失败") + task_manager.fail_task( + task_id, + report.error or tr("report.generation_failed", locale), + locale=locale, + ) except Exception as e: - logger.error(f"报告生成失败: {str(e)}") - task_manager.fail_task(task_id, str(e)) + logger.error(f"{_report_error_context(locale, 'report.error_generation_failed')}: {str(e)}") + task_manager.fail_task(task_id, str(e), locale=locale) # 启动后台线程 thread = threading.Thread(target=run_generate, daemon=True) @@ -181,18 +298,13 @@ def progress_callback(stage, progress, message): "report_id": report_id, "task_id": task_id, "status": "generating", - "message": "报告生成任务已启动,请通过 /api/report/generate/status 查询进度", + "message": tr("report.generation_started", locale), "already_generated": False } }) except Exception as e: - logger.error(f"启动报告生成任务失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_report_api_exception(e, locale, "report.error_start_generation_failed") @report_bp.route('/generate/status', methods=['POST']) @@ -219,6 +331,7 @@ def get_generate_status(): """ try: data = request.get_json() or {} + locale = get_locale() task_id = data.get('task_id') simulation_id = data.get('simulation_id') @@ -234,7 +347,7 @@ def get_generate_status(): "report_id": existing_report.report_id, "status": "completed", "progress": 100, - "message": "报告已生成", + "message": tr("report.already_generated", locale), "already_completed": True } }) @@ -242,7 +355,7 @@ def get_generate_status(): if not task_id: return jsonify({ "success": False, - "error": "请提供 task_id 或 simulation_id" + "error": tr("report.task_or_simulation_required", locale) }), 400 task_manager = TaskManager() @@ -251,16 +364,17 @@ def get_generate_status(): if not task: return jsonify({ "success": False, - "error": f"任务不存在: {task_id}" + "error": tr("report.task_not_found", locale, task_id=task_id) }), 404 return jsonify({ "success": True, - "data": task.to_dict() + "data": _translate_report_progress_payload(locale, task.to_dict()) }) except Exception as e: - logger.error(f"查询任务状态失败: {str(e)}") + locale = get_locale() + logger.error(f"{_report_error_context(locale, 'report.error_task_status_failed')}: {str(e)}") return jsonify({ "success": False, "error": str(e) @@ -289,12 +403,13 @@ def get_report(report_id: str): } """ try: + locale = get_locale() report = ReportManager.get_report(report_id) if not report: return jsonify({ "success": False, - "error": f"报告不存在: {report_id}" + "error": tr("report.not_found", locale, report_id=report_id) }), 404 return jsonify({ @@ -303,12 +418,7 @@ def get_report(report_id: str): }) except Exception as e: - logger.error(f"获取报告失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_report_api_exception(e, locale, "report.error_get_failed") @report_bp.route('/by-simulation/<simulation_id>', methods=['GET']) @@ -326,12 +436,13 @@ def get_report_by_simulation(simulation_id: str): } """ try: + locale = get_locale() report = ReportManager.get_report_by_simulation(simulation_id) if not report: return jsonify({ "success": False, - "error": f"该模拟暂无报告: {simulation_id}", + "error": tr("report.not_available_for_simulation", locale, simulation_id=simulation_id), "has_report": False }), 404 @@ -342,12 +453,7 @@ def get_report_by_simulation(simulation_id: str): }) except Exception as e: - logger.error(f"获取报告失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_report_api_exception(e, locale, "report.error_get_failed") @report_bp.route('/list', methods=['GET']) @@ -382,12 +488,7 @@ def list_reports(): }) except Exception as e: - logger.error(f"列出报告失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_report_api_exception(e, get_locale(), "report.error_list_failed") @report_bp.route('/<report_id>/download', methods=['GET']) @@ -398,15 +499,17 @@ def download_report(report_id: str): 返回Markdown文件 """ try: + locale = get_locale() report = ReportManager.get_report(report_id) if not report: return jsonify({ "success": False, - "error": f"报告不存在: {report_id}" + "error": tr("report.not_found", locale, report_id=report_id) }), 404 md_path = ReportManager._get_report_markdown_path(report_id) + download_name = _build_report_download_name(report) if not os.path.exists(md_path): # 如果MD文件不存在,生成一个临时文件 @@ -418,48 +521,39 @@ def download_report(report_id: str): return send_file( temp_path, as_attachment=True, - download_name=f"{report_id}.md" + download_name=download_name ) return send_file( md_path, as_attachment=True, - download_name=f"{report_id}.md" + download_name=download_name ) except Exception as e: - logger.error(f"下载报告失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_report_api_exception(e, locale, "report.error_download_failed") @report_bp.route('/<report_id>', methods=['DELETE']) def delete_report(report_id: str): """删除报告""" try: + locale = get_locale() success = ReportManager.delete_report(report_id) if not success: return jsonify({ "success": False, - "error": f"报告不存在: {report_id}" + "error": tr("report.not_found", locale, report_id=report_id) }), 404 return jsonify({ "success": True, - "message": f"报告已删除: {report_id}" + "message": tr("report.deleted", locale, report_id=report_id) }) except Exception as e: - logger.error(f"删除报告失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_report_api_exception(e, locale, "report.error_delete_failed") # ============== Report Agent对话接口 ============== @@ -491,6 +585,7 @@ def chat_with_report_agent(): } } """ + locale = get_locale() try: data = request.get_json() or {} @@ -501,13 +596,13 @@ def chat_with_report_agent(): if not simulation_id: return jsonify({ "success": False, - "error": "请提供 simulation_id" + "error": tr("report.simulation_id_required", locale) }), 400 if not message: return jsonify({ "success": False, - "error": "请提供 message" + "error": tr("report.message_required", locale) }), 400 # 获取模拟和项目信息 @@ -517,21 +612,21 @@ def chat_with_report_agent(): if not state: return jsonify({ "success": False, - "error": f"模拟不存在: {simulation_id}" + "error": tr("simulation.not_found", locale, simulation_id=simulation_id) }), 404 project = ProjectManager.get_project(state.project_id) if not project: return jsonify({ "success": False, - "error": f"项目不存在: {state.project_id}" + "error": tr("report.project_not_found", locale, project_id=state.project_id) }), 404 graph_id = state.graph_id or project.graph_id if not graph_id: return jsonify({ "success": False, - "error": "缺少图谱ID" + "error": tr("report.graph_id_required", locale) }), 400 simulation_requirement = project.simulation_requirement or "" @@ -540,7 +635,8 @@ def chat_with_report_agent(): agent = ReportAgent( graph_id=graph_id, simulation_id=simulation_id, - simulation_requirement=simulation_requirement + simulation_requirement=simulation_requirement, + locale=locale, ) result = agent.chat(message=message, chat_history=chat_history) @@ -551,12 +647,7 @@ def chat_with_report_agent(): }) except Exception as e: - logger.error(f"对话失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_report_api_exception(e, locale, "report.error_chat_failed") # ============== 报告进度与分章节接口 ============== @@ -580,26 +671,22 @@ def get_report_progress(report_id: str): } """ try: + locale = get_locale() progress = ReportManager.get_progress(report_id) if not progress: return jsonify({ "success": False, - "error": f"报告不存在或进度信息不可用: {report_id}" + "error": tr("report.progress_not_available", locale, report_id=report_id) }), 404 return jsonify({ "success": True, - "data": progress + "data": _translate_report_progress_payload(locale, progress) }) except Exception as e: - logger.error(f"获取报告进度失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_report_api_exception(e, get_locale(), "report.error_progress_failed") @report_bp.route('/<report_id>/sections', methods=['GET']) @@ -645,12 +732,7 @@ def get_report_sections(report_id: str): }) except Exception as e: - logger.error(f"获取章节列表失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_report_api_exception(e, get_locale(), "report.error_section_list_failed") @report_bp.route('/<report_id>/section/<int:section_index>', methods=['GET']) @@ -668,12 +750,13 @@ def get_single_section(report_id: str, section_index: int): } """ try: + locale = get_locale() section_path = ReportManager._get_section_path(report_id, section_index) if not os.path.exists(section_path): return jsonify({ "success": False, - "error": f"章节不存在: section_{section_index:02d}.md" + "error": tr("report.section_not_found", locale, section_index=section_index) }), 404 with open(section_path, 'r', encoding='utf-8') as f: @@ -689,12 +772,7 @@ def get_single_section(report_id: str, section_index: int): }) except Exception as e: - logger.error(f"获取章节内容失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_report_api_exception(e, get_locale(), "report.error_section_content_failed") # ============== 报告状态检查接口 ============== @@ -740,12 +818,7 @@ def check_report_status(simulation_id: str): }) except Exception as e: - logger.error(f"检查报告状态失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_report_api_exception(e, get_locale(), "report.error_status_failed") # ============== Agent 日志接口 ============== @@ -801,12 +874,7 @@ def get_agent_log(report_id: str): }) except Exception as e: - logger.error(f"获取Agent日志失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_report_api_exception(e, get_locale(), "report.error_agent_log_failed") @report_bp.route('/<report_id>/agent-log/stream', methods=['GET']) @@ -835,12 +903,7 @@ def stream_agent_log(report_id: str): }) except Exception as e: - logger.error(f"获取Agent日志失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_report_api_exception(e, get_locale(), "report.error_agent_log_failed") # ============== 控制台日志接口 ============== @@ -883,12 +946,7 @@ def get_console_log(report_id: str): }) except Exception as e: - logger.error(f"获取控制台日志失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_report_api_exception(e, get_locale(), "report.error_console_log_failed") @report_bp.route('/<report_id>/console-log/stream', methods=['GET']) @@ -917,12 +975,7 @@ def stream_console_log(report_id: str): }) except Exception as e: - logger.error(f"获取控制台日志失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_report_api_exception(e, get_locale(), "report.error_console_log_failed") # ============== 工具调用接口(供调试使用)============== @@ -939,6 +992,7 @@ def search_graph_tool(): "limit": 10 } """ + locale = get_locale() try: data = request.get_json() or {} @@ -949,7 +1003,7 @@ def search_graph_tool(): if not graph_id or not query: return jsonify({ "success": False, - "error": "请提供 graph_id 和 query" + "error": tr("report.graph_id_and_query_required", locale) }), 400 from ..services.zep_tools import ZepToolsService @@ -967,12 +1021,7 @@ def search_graph_tool(): }) except Exception as e: - logger.error(f"图谱搜索失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_report_api_exception(e, get_locale(), "report.error_graph_search_failed") @report_bp.route('/tools/statistics', methods=['POST']) @@ -985,6 +1034,7 @@ def get_graph_statistics_tool(): "graph_id": "mirofish_xxxx" } """ + locale = get_locale() try: data = request.get_json() or {} @@ -993,7 +1043,7 @@ def get_graph_statistics_tool(): if not graph_id: return jsonify({ "success": False, - "error": "请提供 graph_id" + "error": tr("report.graph_id_required_for_tools", locale) }), 400 from ..services.zep_tools import ZepToolsService @@ -1007,9 +1057,4 @@ def get_graph_statistics_tool(): }) except Exception as e: - logger.error(f"获取图谱统计失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_report_api_exception(e, get_locale(), "report.error_graph_stats_failed") diff --git a/backend/app/api/simulation.py b/backend/app/api/simulation.py index 3a0f6816..67a20275 100644 --- a/backend/app/api/simulation.py +++ b/backend/app/api/simulation.py @@ -4,27 +4,190 @@ """ import os +import re import traceback from flask import request, jsonify, send_file from . import simulation_bp from ..config import Config +from ..i18n import get_locale, tr from ..services.zep_entity_reader import ZepEntityReader from ..services.oasis_profile_generator import OasisProfileGenerator from ..services.simulation_manager import SimulationManager, SimulationStatus from ..services.simulation_runner import SimulationRunner, RunnerStatus +from ..services.report_agent import ReportManager +from ..utils.error_handler import handle_api_exception from ..utils.logger import get_logger from ..models.project import ProjectManager logger = get_logger('mirofish.api.simulation') +RUN_STATUS_DETAIL_DEFAULT_LIMIT = 200 +RUN_STATUS_DETAIL_MAX_LIMIT = 1000 +RUN_STATUS_DETAIL_RECENT_ACTIONS_LIMIT = 200 + # Interview prompt 优化前缀 # 添加此前缀可以避免Agent调用工具,直接用文本回复 -INTERVIEW_PROMPT_PREFIX = "结合你的人设、所有的过往记忆与行动,不调用任何工具直接用文本回复我:" +INTERVIEW_PROMPT_PREFIXES = { + "zh": "结合你的人设、所有的过往记忆与行动,不调用任何工具直接用文本回复我:", + "en": "Based on your persona, all prior memories, and past actions, reply to me directly in plain text without calling any tools: ", +} + +SIMULATION_STAGE_NAMES_EN = { + "reading": "Reading graph entities", + "generating_profiles": "Generating agent profiles", + "generating_config": "Generating simulation config", + "copying_scripts": "Preparing simulation scripts", +} + +SIMULATION_STAGE_LABELS_EN = { + **SIMULATION_STAGE_NAMES_EN, + "读取图谱实体": "Reading graph entities", + "生成Agent人设": "Generating agent profiles", + "生成模拟配置": "Generating simulation config", + "准备模拟脚本": "Preparing simulation scripts", +} + +SIMULATION_PROGRESS_MESSAGE_MAP = { + "开始准备模拟环境...": "Preparing the simulation environment...", + "正在连接Zep图谱...": "Connecting to the Zep graph...", + "正在读取节点数据...": "Reading node data...", + "开始生成...": "Starting generation...", + "保存Profile文件...": "Saving profile files...", + "正在分析模拟需求...": "Analyzing the simulation requirement...", + "正在调用LLM生成配置...": "Calling the LLM to generate the config...", + "正在保存配置文件...": "Saving the config file...", + "配置生成完成": "Configuration generation completed", +} + +ACTIVE_RUNNER_STATUSES = { + RunnerStatus.STARTING, + RunnerStatus.RUNNING, + RunnerStatus.PAUSED, + RunnerStatus.STOPPING, +} + + +def _simulation_stage_name(stage: str, locale: str | None = None) -> str: + return tr(f"simulation.prepare_stage_{stage}", locale) if stage in SIMULATION_STAGE_NAMES_EN else stage + +SIMULATION_ERROR_CONTEXTS_EN = { + "获取图谱实体失败": "Failed to get graph entities", + "获取实体详情失败": "Failed to get entity details", + "获取实体失败": "Failed to get entities", + "创建模拟失败": "Failed to create the simulation", + "启动准备任务失败": "Failed to start the preparation task", + "获取模拟状态失败": "Failed to get the simulation status", + "列出模拟失败": "Failed to list simulations", + "获取历史模拟失败": "Failed to get simulation history", + "获取Profile失败": "Failed to get profiles", + "实时获取Profile失败": "Failed to get live profiles", + "实时获取Config失败": "Failed to get the live config", + "获取配置失败": "Failed to get the config", + "下载配置失败": "Failed to download the config", + "下载脚本失败": "Failed to download the scripts", + "生成Profile失败": "Failed to generate profiles", + "启动模拟失败": "Failed to start the simulation", + "停止模拟失败": "Failed to stop the simulation", + "获取运行状态失败": "Failed to get run status", + "获取详细状态失败": "Failed to get detailed run status", + "获取动作历史失败": "Failed to get the action history", + "获取时间线失败": "Failed to get the timeline", + "获取Agent统计失败": "Failed to get agent statistics", + "获取帖子失败": "Failed to get posts", + "获取评论失败": "Failed to get comments", + "Interview失败": "Interview request failed", + "批量Interview失败": "Batch interview request failed", + "全局Interview失败": "Global interview request failed", + "获取Interview历史失败": "Failed to get interview history", + "获取环境状态失败": "Failed to get environment status", + "关闭环境失败": "Failed to close the environment", +} + + +def _translate_simulation_progress_message(locale: str, message: str | None) -> str | None: + if locale != "en" or not message: + return message + + translated = SIMULATION_PROGRESS_MESSAGE_MAP.get(message, message) + + completed_entities = re.match(r"^完成,共 (?P<count>\d+) 个实体$", translated) + if completed_entities: + return f"Completed with {completed_entities.group('count')} entities" + + completed_profiles = re.match(r"^完成,共 (?P<count>\d+) 个Profile$", translated) + if completed_profiles: + return f"Completed with {completed_profiles.group('count')} profiles" + + numbered_status = re.match( + r"^\[(?P<index>\d+)/(?P<total>\d+)\] (?P<stage>.+?): (?P<body>.+)$", + translated, + ) + if numbered_status: + stage_name = numbered_status.group("stage") + stage_name = SIMULATION_STAGE_LABELS_EN.get(stage_name, stage_name) + body = _translate_simulation_progress_message(locale, numbered_status.group("body")) or "" + return f"[{numbered_status.group('index')}/{numbered_status.group('total')}] {stage_name}: {body}" + + return translated + + +def _translate_prepare_task_payload(locale: str, payload: dict | None) -> dict | None: + if locale != "en" or not payload: + return payload + + translated_payload = dict(payload) + translated_payload["message"] = _translate_simulation_progress_message(locale, payload.get("message")) + + progress_detail = payload.get("progress_detail") + if isinstance(progress_detail, dict): + translated_detail = dict(progress_detail) + stage = translated_detail.get("current_stage") + translated_detail["current_stage_name"] = ( + _simulation_stage_name(stage, locale) + if stage + else translated_detail.get("current_stage_name") + ) + translated_detail["item_description"] = _translate_simulation_progress_message( + locale, + translated_detail.get("item_description"), + ) + translated_payload["progress_detail"] = translated_detail + + return translated_payload + + +def _simulation_error_context(locale: str, context: str) -> str: + if locale == "en": + return SIMULATION_ERROR_CONTEXTS_EN.get(context, context) + return context + + +def _simulation_log(locale: str, zh_template: str, en_template: str, **params) -> str: + template = en_template if locale == "en" else zh_template + return template.format(**params) + + +def _handle_simulation_api_exception(error: Exception, locale: str, context: str): + return handle_api_exception(logger, error, _simulation_error_context(locale, context)) + + +def _resolve_simulation_platform(manager: SimulationManager, simulation_id: str, platform: str | None) -> str: + """Resolve platform requests against the simulation's enabled platform set.""" + normalized = platform.strip().lower() if isinstance(platform, str) else None + if normalized == "": + normalized = None + + if manager.get_simulation(simulation_id) is None: + if normalized and normalized not in ("twitter", "reddit"): + raise ValueError(tr("simulation.platform_invalid", get_locale())) + return normalized or "reddit" + + return manager.resolve_platform(simulation_id, platform) -def optimize_interview_prompt(prompt: str) -> str: +def optimize_interview_prompt(prompt: str, locale: str | None = None) -> str: """ 优化Interview提问,添加前缀避免Agent调用工具 @@ -36,10 +199,28 @@ def optimize_interview_prompt(prompt: str) -> str: """ if not prompt: return prompt + resolved_locale = get_locale(locale) + prefix = INTERVIEW_PROMPT_PREFIXES.get(resolved_locale, INTERVIEW_PROMPT_PREFIXES["zh"]) # 避免重复添加前缀 - if prompt.startswith(INTERVIEW_PROMPT_PREFIX): + if any(prompt.startswith(candidate) for candidate in INTERVIEW_PROMPT_PREFIXES.values()): return prompt - return f"{INTERVIEW_PROMPT_PREFIX}{prompt}" + return f"{prefix}{prompt}" + + +def resolve_interview_timeout(raw_timeout, default_timeout: float, locale: str | None = None) -> float: + """Parse a positive interview timeout without silently accepting invalid values.""" + if raw_timeout in (None, ''): + return default_timeout + + try: + timeout = float(raw_timeout) + except (TypeError, ValueError) as exc: + raise ValueError(tr("simulation.timeout_invalid_number", locale)) from exc + + if timeout <= 0: + raise ValueError(tr("simulation.timeout_invalid_nonpositive", locale)) + + return timeout # ============== 实体读取接口 ============== @@ -56,17 +237,27 @@ def get_graph_entities(graph_id: str): enrich: 是否获取相关边信息(默认true) """ try: + locale = get_locale() if not Config.ZEP_API_KEY: return jsonify({ "success": False, - "error": "ZEP_API_KEY未配置" + "error": tr("graph.zep_key_missing", locale) }), 500 entity_types_str = request.args.get('entity_types', '') entity_types = [t.strip() for t in entity_types_str.split(',') if t.strip()] if entity_types_str else None enrich = request.args.get('enrich', 'true').lower() == 'true' - logger.info(f"获取图谱实体: graph_id={graph_id}, entity_types={entity_types}, enrich={enrich}") + logger.info( + _simulation_log( + locale, + "获取图谱实体: graph_id={graph_id}, entity_types={entity_types}, enrich={enrich}", + "Fetching graph entities: graph_id={graph_id}, entity_types={entity_types}, enrich={enrich}", + graph_id=graph_id, + entity_types=entity_types, + enrich=enrich, + ) + ) reader = ZepEntityReader() result = reader.filter_defined_entities( @@ -81,22 +272,18 @@ def get_graph_entities(graph_id: str): }) except Exception as e: - logger.error(f"获取图谱实体失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_simulation_api_exception(e, get_locale(), "获取图谱实体失败") @simulation_bp.route('/entities/<graph_id>/<entity_uuid>', methods=['GET']) def get_entity_detail(graph_id: str, entity_uuid: str): """获取单个实体的详细信息""" try: + locale = get_locale() if not Config.ZEP_API_KEY: return jsonify({ "success": False, - "error": "ZEP_API_KEY未配置" + "error": tr("graph.zep_key_missing", locale) }), 500 reader = ZepEntityReader() @@ -105,7 +292,7 @@ def get_entity_detail(graph_id: str, entity_uuid: str): if not entity: return jsonify({ "success": False, - "error": f"实体不存在: {entity_uuid}" + "error": tr("simulation.entity_not_found", locale, entity_uuid=entity_uuid) }), 404 return jsonify({ @@ -114,22 +301,18 @@ def get_entity_detail(graph_id: str, entity_uuid: str): }) except Exception as e: - logger.error(f"获取实体详情失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_simulation_api_exception(e, get_locale(), "获取实体详情失败") @simulation_bp.route('/entities/<graph_id>/by-type/<entity_type>', methods=['GET']) def get_entities_by_type(graph_id: str, entity_type: str): """获取指定类型的所有实体""" try: + locale = get_locale() if not Config.ZEP_API_KEY: return jsonify({ "success": False, - "error": "ZEP_API_KEY未配置" + "error": tr("graph.zep_key_missing", locale) }), 500 enrich = request.args.get('enrich', 'true').lower() == 'true' @@ -151,12 +334,7 @@ def get_entities_by_type(graph_id: str, entity_type: str): }) except Exception as e: - logger.error(f"获取实体失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_simulation_api_exception(e, get_locale(), "获取实体失败") # ============== 模拟管理接口 ============== @@ -192,26 +370,27 @@ def create_simulation(): """ try: data = request.get_json() or {} + locale = get_locale() project_id = data.get('project_id') if not project_id: return jsonify({ "success": False, - "error": "请提供 project_id" + "error": tr("graph.project_id_required", locale) }), 400 project = ProjectManager.get_project(project_id) if not project: return jsonify({ "success": False, - "error": f"项目不存在: {project_id}" + "error": tr("graph.project_not_found", locale, project_id=project_id) }), 404 graph_id = data.get('graph_id') or project.graph_id if not graph_id: return jsonify({ "success": False, - "error": "项目尚未构建图谱,请先调用 /api/graph/build" + "error": tr("simulation.project_graph_required", locale) }), 400 manager = SimulationManager() @@ -228,15 +407,10 @@ def create_simulation(): }) except Exception as e: - logger.error(f"创建模拟失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_simulation_api_exception(e, get_locale(), "创建模拟失败") -def _check_simulation_prepared(simulation_id: str) -> tuple: +def _check_simulation_prepared(simulation_id: str, locale: str | None = None) -> tuple: """ 检查模拟是否已经准备完成 @@ -256,10 +430,11 @@ def _check_simulation_prepared(simulation_id: str) -> tuple: from ..config import Config simulation_dir = os.path.join(Config.OASIS_SIMULATION_DATA_DIR, simulation_id) + resolved_locale = get_locale(locale) # 检查目录是否存在 if not os.path.exists(simulation_dir): - return False, {"reason": "模拟目录不存在"} + return False, {"reason": tr("simulation.prepare_dir_missing", resolved_locale)} # 必要文件列表(不包括脚本,脚本位于 backend/scripts/) required_files = [ @@ -281,7 +456,7 @@ def _check_simulation_prepared(simulation_id: str) -> tuple: if missing_files: return False, { - "reason": "缺少必要文件", + "reason": tr("simulation.prepare_missing_files", resolved_locale), "missing_files": missing_files, "existing_files": existing_files } @@ -297,7 +472,15 @@ def _check_simulation_prepared(simulation_id: str) -> tuple: config_generated = state_data.get("config_generated", False) # 详细日志 - logger.debug(f"检测模拟准备状态: {simulation_id}, status={status}, config_generated={config_generated}") + logger.debug( + tr( + "simulation.prepare_check_status", + resolved_locale, + simulation_id=simulation_id, + status=status, + config_generated=config_generated, + ) + ) # 如果 config_generated=True 且文件存在,认为准备完成 # 以下状态都说明准备工作已完成: @@ -327,12 +510,32 @@ def _check_simulation_prepared(simulation_id: str) -> tuple: state_data["updated_at"] = datetime.now().isoformat() with open(state_file, 'w', encoding='utf-8') as f: json.dump(state_data, f, ensure_ascii=False, indent=2) - logger.info(f"自动更新模拟状态: {simulation_id} preparing -> ready") + logger.info( + tr( + "simulation.prepare_auto_ready", + resolved_locale, + simulation_id=simulation_id, + ) + ) status = "ready" except Exception as e: - logger.warning(f"自动更新状态失败: {e}") - - logger.info(f"模拟 {simulation_id} 检测结果: 已准备完成 (status={status}, config_generated={config_generated})") + logger.warning( + tr( + "simulation.prepare_auto_ready_failed", + resolved_locale, + error=str(e), + ) + ) + + logger.info( + tr( + "simulation.prepare_check_ready", + resolved_locale, + simulation_id=simulation_id, + status=status, + config_generated=config_generated, + ) + ) return True, { "status": status, "entities_count": state_data.get("entities_count", 0), @@ -344,15 +547,30 @@ def _check_simulation_prepared(simulation_id: str) -> tuple: "existing_files": existing_files } else: - logger.warning(f"模拟 {simulation_id} 检测结果: 未准备完成 (status={status}, config_generated={config_generated})") + logger.warning( + tr( + "simulation.prepare_check_not_ready", + resolved_locale, + simulation_id=simulation_id, + status=status, + config_generated=config_generated, + ) + ) return False, { - "reason": f"状态不在已准备列表中或config_generated为false: status={status}, config_generated={config_generated}", + "reason": tr( + "simulation.prepare_status_not_ready", + resolved_locale, + status=status, + config_generated=config_generated, + ), "status": status, "config_generated": config_generated } except Exception as e: - return False, {"reason": f"读取状态文件失败: {str(e)}"} + return False, { + "reason": tr("simulation.prepare_state_read_failed", resolved_locale, details=str(e)) + } @simulation_bp.route('/prepare', methods=['POST']) @@ -403,12 +621,13 @@ def prepare_simulation(): try: data = request.get_json() or {} + locale = get_locale() simulation_id = data.get('simulation_id') if not simulation_id: return jsonify({ "success": False, - "error": "请提供 simulation_id" + "error": tr("simulation.simulation_id_required", locale) }), 400 manager = SimulationManager() @@ -417,39 +636,76 @@ def prepare_simulation(): if not state: return jsonify({ "success": False, - "error": f"模拟不存在: {simulation_id}" + "error": tr("simulation.not_found", locale, simulation_id=simulation_id) }), 404 # 检查是否强制重新生成 force_regenerate = data.get('force_regenerate', False) - logger.info(f"开始处理 /prepare 请求: simulation_id={simulation_id}, force_regenerate={force_regenerate}") + logger.info( + _simulation_log( + locale, + "开始处理 /prepare 请求: simulation_id={simulation_id}, force_regenerate={force_regenerate}", + "Handling /prepare request: simulation_id={simulation_id}, force_regenerate={force_regenerate}", + simulation_id=simulation_id, + force_regenerate=force_regenerate, + ) + ) # 检查是否已经准备完成(避免重复生成) if not force_regenerate: - logger.debug(f"检查模拟 {simulation_id} 是否已准备完成...") - is_prepared, prepare_info = _check_simulation_prepared(simulation_id) - logger.debug(f"检查结果: is_prepared={is_prepared}, prepare_info={prepare_info}") + logger.debug( + _simulation_log( + locale, + "检查模拟 {simulation_id} 是否已准备完成...", + "Checking whether simulation {simulation_id} is already prepared...", + simulation_id=simulation_id, + ) + ) + is_prepared, prepare_info = _check_simulation_prepared(simulation_id, locale) + logger.debug( + _simulation_log( + locale, + "检查结果: is_prepared={is_prepared}, prepare_info={prepare_info}", + "Prepare check result: is_prepared={is_prepared}, prepare_info={prepare_info}", + is_prepared=is_prepared, + prepare_info=prepare_info, + ) + ) if is_prepared: - logger.info(f"模拟 {simulation_id} 已准备完成,跳过重复生成") + logger.info( + _simulation_log( + locale, + "模拟 {simulation_id} 已准备完成,跳过重复生成", + "Simulation {simulation_id} is already prepared; skipping duplicate generation", + simulation_id=simulation_id, + ) + ) return jsonify({ "success": True, "data": { "simulation_id": simulation_id, "status": "ready", - "message": "已有完成的准备工作,无需重复生成", + "message": tr("simulation.prepare_already_done", locale), "already_prepared": True, "prepare_info": prepare_info } }) else: - logger.info(f"模拟 {simulation_id} 未准备完成,将启动准备任务") + logger.info( + _simulation_log( + locale, + "模拟 {simulation_id} 未准备完成,将启动准备任务", + "Simulation {simulation_id} is not prepared yet; starting the preparation task", + simulation_id=simulation_id, + ) + ) # 从项目获取必要信息 project = ProjectManager.get_project(state.project_id) if not project: return jsonify({ "success": False, - "error": f"项目不存在: {state.project_id}" + "error": tr("graph.project_not_found", locale, project_id=state.project_id) }), 404 # 获取模拟需求 @@ -457,7 +713,7 @@ def prepare_simulation(): if not simulation_requirement: return jsonify({ "success": False, - "error": "项目缺少模拟需求描述 (simulation_requirement)" + "error": tr("simulation.project_requirement_required", locale) }), 400 # 获取文档文本 @@ -470,7 +726,14 @@ def prepare_simulation(): # ========== 同步获取实体数量(在后台任务启动前) ========== # 这样前端在调用prepare后立即就能获取到预期Agent总数 try: - logger.info(f"同步获取实体数量: graph_id={state.graph_id}") + logger.info( + _simulation_log( + locale, + "同步获取实体数量: graph_id={graph_id}", + "Preloading entity count synchronously: graph_id={graph_id}", + graph_id=state.graph_id, + ) + ) reader = ZepEntityReader() # 快速读取实体(不需要边信息,只统计数量) filtered_preview = reader.filter_defined_entities( @@ -481,9 +744,24 @@ def prepare_simulation(): # 保存实体数量到状态(供前端立即获取) state.entities_count = filtered_preview.filtered_count state.entity_types = list(filtered_preview.entity_types) - logger.info(f"预期实体数量: {filtered_preview.filtered_count}, 类型: {filtered_preview.entity_types}") + logger.info( + _simulation_log( + locale, + "预期实体数量: {count}, 类型: {entity_types}", + "Expected entity count: {count}, types: {entity_types}", + count=filtered_preview.filtered_count, + entity_types=filtered_preview.entity_types, + ) + ) except Exception as e: - logger.warning(f"同步获取实体数量失败(将在后台任务中重试): {e}") + logger.warning( + _simulation_log( + locale, + "同步获取实体数量失败(将在后台任务中重试): {error}", + "Failed to preload entity count synchronously; the background task will retry: {error}", + error=e, + ) + ) # 失败不影响后续流程,后台任务会重新获取 # 创建异步任务 @@ -507,7 +785,7 @@ def run_prepare(): task_id, status=TaskStatus.PROCESSING, progress=0, - message="开始准备模拟环境..." + message=tr("simulation.prepare_initializing", locale) ) # 准备模拟(带进度回调) @@ -528,10 +806,10 @@ def progress_callback(stage, progress, message, **kwargs): # 构建详细进度信息 stage_names = { - "reading": "读取图谱实体", - "generating_profiles": "生成Agent人设", - "generating_config": "生成模拟配置", - "copying_scripts": "准备模拟脚本" + "reading": _simulation_stage_name("reading", locale), + "generating_profiles": _simulation_stage_name("generating_profiles", locale), + "generating_config": _simulation_stage_name("generating_config", locale), + "copying_scripts": _simulation_stage_name("copying_scripts", locale), } stage_index = list(stage_weights.keys()).index(stage) + 1 if stage in stage_weights else 1 @@ -582,18 +860,27 @@ def progress_callback(stage, progress, message, **kwargs): defined_entity_types=entity_types_list, use_llm_for_profiles=use_llm_for_profiles, progress_callback=progress_callback, - parallel_profile_count=parallel_profile_count + parallel_profile_count=parallel_profile_count, + locale=locale, ) # 任务完成 task_manager.complete_task( task_id, - result=result_state.to_simple_dict() + result=result_state.to_simple_dict(), + locale=locale, ) except Exception as e: - logger.error(f"准备模拟失败: {str(e)}") - task_manager.fail_task(task_id, str(e)) + logger.error( + _simulation_log( + locale, + "准备模拟失败: {error}", + "Simulation preparation failed: {error}", + error=str(e), + ) + ) + task_manager.fail_task(task_id, str(e), locale=locale) # 更新模拟状态为失败 state = manager.get_simulation(simulation_id) @@ -612,7 +899,7 @@ def progress_callback(stage, progress, message, **kwargs): "simulation_id": simulation_id, "task_id": task_id, "status": "preparing", - "message": "准备任务已启动,请通过 /api/simulation/prepare/status 查询进度", + "message": tr("simulation.prepare_started", locale), "already_prepared": False, "expected_entities_count": state.entities_count, # 预期的Agent总数 "entity_types": state.entity_types # 实体类型列表 @@ -626,12 +913,7 @@ def progress_callback(stage, progress, message, **kwargs): }), 404 except Exception as e: - logger.error(f"启动准备任务失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_simulation_api_exception(e, get_locale(), "启动准备任务失败") @simulation_bp.route('/prepare/status', methods=['POST']) @@ -666,13 +948,14 @@ def get_prepare_status(): try: data = request.get_json() or {} + locale = get_locale() task_id = data.get('task_id') simulation_id = data.get('simulation_id') # 如果提供了simulation_id,先检查是否已准备完成 if simulation_id: - is_prepared, prepare_info = _check_simulation_prepared(simulation_id) + is_prepared, prepare_info = _check_simulation_prepared(simulation_id, locale) if is_prepared: return jsonify({ "success": True, @@ -680,7 +963,7 @@ def get_prepare_status(): "simulation_id": simulation_id, "status": "ready", "progress": 100, - "message": "已有完成的准备工作", + "message": tr("simulation.prepare_exists_short", locale), "already_prepared": True, "prepare_info": prepare_info } @@ -696,13 +979,13 @@ def get_prepare_status(): "simulation_id": simulation_id, "status": "not_started", "progress": 0, - "message": "尚未开始准备,请调用 /api/simulation/prepare 开始", + "message": tr("simulation.prepare_not_started", locale), "already_prepared": False } }) return jsonify({ "success": False, - "error": "请提供 task_id 或 simulation_id" + "error": tr("report.task_or_simulation_required", locale) }), 400 task_manager = TaskManager() @@ -711,7 +994,7 @@ def get_prepare_status(): if not task: # 任务不存在,但如果有simulation_id,检查是否已准备完成 if simulation_id: - is_prepared, prepare_info = _check_simulation_prepared(simulation_id) + is_prepared, prepare_info = _check_simulation_prepared(simulation_id, locale) if is_prepared: return jsonify({ "success": True, @@ -720,7 +1003,7 @@ def get_prepare_status(): "task_id": task_id, "status": "ready", "progress": 100, - "message": "任务已完成(准备工作已存在)", + "message": tr("simulation.prepare_task_completed_existing", locale), "already_prepared": True, "prepare_info": prepare_info } @@ -728,7 +1011,7 @@ def get_prepare_status(): return jsonify({ "success": False, - "error": f"任务不存在: {task_id}" + "error": tr("report.task_not_found", locale, task_id=task_id) }), 404 task_dict = task.to_dict() @@ -736,11 +1019,11 @@ def get_prepare_status(): return jsonify({ "success": True, - "data": task_dict + "data": _translate_prepare_task_payload(locale, task_dict) }) except Exception as e: - logger.error(f"查询任务状态失败: {str(e)}") + logger.error(tr("simulation.task_status_query_failed", get_locale(), error=str(e))) return jsonify({ "success": False, "error": str(e) @@ -751,20 +1034,21 @@ def get_prepare_status(): def get_simulation(simulation_id: str): """获取模拟状态""" try: + locale = get_locale() manager = SimulationManager() state = manager.get_simulation(simulation_id) if not state: return jsonify({ "success": False, - "error": f"模拟不存在: {simulation_id}" + "error": tr("simulation.not_found", locale, simulation_id=simulation_id) }), 404 result = state.to_dict() # 如果模拟已准备好,附加运行说明 if state.status == SimulationStatus.READY: - result["run_instructions"] = manager.get_run_instructions(simulation_id) + result["run_instructions"] = manager.get_run_instructions(simulation_id, locale=locale) return jsonify({ "success": True, @@ -772,12 +1056,7 @@ def get_simulation(simulation_id: str): }) except Exception as e: - logger.error(f"获取模拟状态失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_simulation_api_exception(e, get_locale(), "获取模拟状态失败") @simulation_bp.route('/list', methods=['GET']) @@ -801,12 +1080,7 @@ def list_simulations(): }) except Exception as e: - logger.error(f"列出模拟失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_simulation_api_exception(e, get_locale(), "列出模拟失败") def _get_report_id_for_simulation(simulation_id: str) -> str: @@ -864,7 +1138,14 @@ def _get_report_id_for_simulation(simulation_id: str) -> str: return matching_reports[0].get("report_id") except Exception as e: - logger.warning(f"查找 simulation {simulation_id} 的 report 失败: {e}") + logger.warning( + tr( + "simulation.report_lookup_failed", + get_locale(), + simulation_id=simulation_id, + error=str(e), + ) + ) return None @@ -962,7 +1243,7 @@ def get_simulation_history(): try: created_date = sim_dict.get("created_at", "")[:10] sim_dict["created_date"] = created_date - except: + except Exception: sim_dict["created_date"] = "" enriched_simulations.append(sim_dict) @@ -974,12 +1255,65 @@ def get_simulation_history(): }) except Exception as e: - logger.error(f"获取历史模拟失败: {str(e)}") + return _handle_simulation_api_exception(e, get_locale(), "获取历史模拟失败") + + +@simulation_bp.route('/history/<simulation_id>', methods=['DELETE']) +def delete_simulation_history(simulation_id: str): + """删除首页历史记录关联的本地模拟资产。""" + try: + locale = get_locale() + manager = SimulationManager() + simulation = manager.get_simulation(simulation_id) + + if not simulation: + return jsonify({ + "success": False, + "error": tr("simulation.not_found", locale, simulation_id=simulation_id) + }), 404 + + run_state = SimulationRunner.get_run_state(simulation_id) + if run_state and run_state.runner_status in ACTIVE_RUNNER_STATUSES: + return jsonify({ + "success": False, + "error": tr("simulation.delete_active", locale, simulation_id=simulation_id) + }), 409 + + reports = ReportManager.list_reports(simulation_id=simulation_id, limit=1000) + deleted_report_ids = [] + for report in reports: + if ReportManager.delete_report(report.report_id, locale=locale): + deleted_report_ids.append(report.report_id) + + project_deleted = False + project_id = simulation.project_id + simulation_deleted = manager.delete_simulation(simulation_id) + if not simulation_deleted: + return jsonify({ + "success": False, + "error": tr("simulation.not_found", locale, simulation_id=simulation_id) + }), 404 + + SimulationRunner._run_states.pop(simulation_id, None) + + if project_id: + remaining_project_simulations = manager.list_simulations(project_id=project_id) + if not remaining_project_simulations: + project_deleted = ProjectManager.delete_project(project_id) + return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + "success": True, + "message": tr("simulation.deleted", locale, simulation_id=simulation_id), + "data": { + "simulation_id": simulation_id, + "project_id": project_id, + "project_deleted": project_deleted, + "deleted_report_ids": deleted_report_ids, + } + }) + + except Exception as e: + return _handle_simulation_api_exception(e, get_locale(), "获取历史模拟失败") @simulation_bp.route('/<simulation_id>/profiles', methods=['GET']) @@ -988,12 +1322,11 @@ def get_simulation_profiles(simulation_id: str): 获取模拟的Agent Profile Query参数: - platform: 平台类型(reddit/twitter,默认reddit) + platform: 平台类型(reddit/twitter,未指定时按模拟配置推断) """ try: - platform = request.args.get('platform', 'reddit') - manager = SimulationManager() + platform = _resolve_simulation_platform(manager, simulation_id, request.args.get('platform')) profiles = manager.get_profiles(simulation_id, platform=platform) return jsonify({ @@ -1012,12 +1345,7 @@ def get_simulation_profiles(simulation_id: str): }), 404 except Exception as e: - logger.error(f"获取Profile失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_simulation_api_exception(e, get_locale(), "获取Profile失败") @simulation_bp.route('/<simulation_id>/profiles/realtime', methods=['GET']) @@ -1031,7 +1359,7 @@ def get_simulation_profiles_realtime(simulation_id: str): - 返回额外的元数据(如文件修改时间、是否正在生成等) Query参数: - platform: 平台类型(reddit/twitter,默认reddit) + platform: 平台类型(reddit/twitter,未指定时按模拟配置推断) 返回: { @@ -1053,7 +1381,9 @@ def get_simulation_profiles_realtime(simulation_id: str): from datetime import datetime try: - platform = request.args.get('platform', 'reddit') + locale = get_locale() + manager = SimulationManager() + platform = _resolve_simulation_platform(manager, simulation_id, request.args.get('platform')) # 获取模拟目录 sim_dir = os.path.join(Config.OASIS_SIMULATION_DATA_DIR, simulation_id) @@ -1061,7 +1391,7 @@ def get_simulation_profiles_realtime(simulation_id: str): if not os.path.exists(sim_dir): return jsonify({ "success": False, - "error": f"模拟不存在: {simulation_id}" + "error": tr("simulation.not_found", locale, simulation_id=simulation_id) }), 404 # 确定文件路径 @@ -1089,7 +1419,13 @@ def get_simulation_profiles_realtime(simulation_id: str): reader = csv.DictReader(f) profiles = list(reader) except (json.JSONDecodeError, Exception) as e: - logger.warning(f"读取 profiles 文件失败(可能正在写入中): {e}") + logger.warning( + tr( + "simulation.realtime_profiles_read_failed", + locale, + error=str(e), + ) + ) profiles = [] # 检查是否正在生成(通过 state.json 判断) @@ -1122,12 +1458,7 @@ def get_simulation_profiles_realtime(simulation_id: str): }) except Exception as e: - logger.error(f"实时获取Profile失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_simulation_api_exception(e, get_locale(), "实时获取Profile失败") @simulation_bp.route('/<simulation_id>/config/realtime', methods=['GET']) @@ -1158,13 +1489,14 @@ def get_simulation_config_realtime(simulation_id: str): from datetime import datetime try: + locale = get_locale() # 获取模拟目录 sim_dir = os.path.join(Config.OASIS_SIMULATION_DATA_DIR, simulation_id) if not os.path.exists(sim_dir): return jsonify({ "success": False, - "error": f"模拟不存在: {simulation_id}" + "error": tr("simulation.not_found", locale, simulation_id=simulation_id) }), 404 # 配置文件路径 @@ -1184,7 +1516,13 @@ def get_simulation_config_realtime(simulation_id: str): with open(config_file, 'r', encoding='utf-8') as f: config = json.load(f) except (json.JSONDecodeError, Exception) as e: - logger.warning(f"读取 config 文件失败(可能正在写入中): {e}") + logger.warning( + tr( + "simulation.realtime_config_read_failed", + locale, + error=str(e), + ) + ) config = None # 检查是否正在生成(通过 state.json 判断) @@ -1242,12 +1580,7 @@ def get_simulation_config_realtime(simulation_id: str): }) except Exception as e: - logger.error(f"实时获取Config失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_simulation_api_exception(e, get_locale(), "实时获取Config失败") @simulation_bp.route('/<simulation_id>/config', methods=['GET']) @@ -1263,13 +1596,14 @@ def get_simulation_config(simulation_id: str): - generation_reasoning: LLM的配置推理说明 """ try: + locale = get_locale() manager = SimulationManager() config = manager.get_simulation_config(simulation_id) if not config: return jsonify({ "success": False, - "error": f"模拟配置不存在,请先调用 /prepare 接口" + "error": tr("simulation.config_missing_prepare", locale) }), 404 return jsonify({ @@ -1278,18 +1612,14 @@ def get_simulation_config(simulation_id: str): }) except Exception as e: - logger.error(f"获取配置失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_simulation_api_exception(e, get_locale(), "获取配置失败") @simulation_bp.route('/<simulation_id>/config/download', methods=['GET']) def download_simulation_config(simulation_id: str): """下载模拟配置文件""" try: + locale = get_locale() manager = SimulationManager() sim_dir = manager._get_simulation_dir(simulation_id) config_path = os.path.join(sim_dir, "simulation_config.json") @@ -1297,7 +1627,7 @@ def download_simulation_config(simulation_id: str): if not os.path.exists(config_path): return jsonify({ "success": False, - "error": "配置文件不存在,请先调用 /prepare 接口" + "error": tr("simulation.config_file_missing_prepare", locale) }), 404 return send_file( @@ -1307,12 +1637,7 @@ def download_simulation_config(simulation_id: str): ) except Exception as e: - logger.error(f"下载配置失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_simulation_api_exception(e, get_locale(), "下载配置失败") @simulation_bp.route('/script/<script_name>/download', methods=['GET']) @@ -1327,6 +1652,7 @@ def download_simulation_script(script_name: str): - action_logger.py """ try: + locale = get_locale() # 脚本位于 backend/scripts/ 目录 scripts_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../scripts')) @@ -1341,7 +1667,12 @@ def download_simulation_script(script_name: str): if script_name not in allowed_scripts: return jsonify({ "success": False, - "error": f"未知脚本: {script_name},可选: {allowed_scripts}" + "error": tr( + "simulation.script_unknown", + locale, + script_name=script_name, + allowed=allowed_scripts, + ) }), 400 script_path = os.path.join(scripts_dir, script_name) @@ -1349,7 +1680,7 @@ def download_simulation_script(script_name: str): if not os.path.exists(script_path): return jsonify({ "success": False, - "error": f"脚本文件不存在: {script_name}" + "error": tr("simulation.script_missing", locale, script_name=script_name) }), 404 return send_file( @@ -1359,12 +1690,7 @@ def download_simulation_script(script_name: str): ) except Exception as e: - logger.error(f"下载脚本失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_simulation_api_exception(e, get_locale(), "下载脚本失败") # ============== Profile生成接口(独立使用) ============== @@ -1384,12 +1710,13 @@ def generate_profiles(): """ try: data = request.get_json() or {} + locale = get_locale() graph_id = data.get('graph_id') if not graph_id: return jsonify({ "success": False, - "error": "请提供 graph_id" + "error": tr("simulation.graph_id_required", locale) }), 400 entity_types = data.get('entity_types') @@ -1406,10 +1733,10 @@ def generate_profiles(): if filtered.filtered_count == 0: return jsonify({ "success": False, - "error": "没有找到符合条件的实体" + "error": tr("simulation.no_matching_entities", locale) }), 400 - generator = OasisProfileGenerator() + generator = OasisProfileGenerator(locale=locale) profiles = generator.generate_profiles_from_entities( entities=filtered.entities, use_llm=use_llm @@ -1433,12 +1760,7 @@ def generate_profiles(): }) except Exception as e: - logger.error(f"生成Profile失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_simulation_api_exception(e, get_locale(), "生成Profile失败") # ============== 模拟运行控制接口 ============== @@ -1486,12 +1808,13 @@ def start_simulation(): """ try: data = request.get_json() or {} + locale = get_locale() simulation_id = data.get('simulation_id') if not simulation_id: return jsonify({ "success": False, - "error": "请提供 simulation_id" + "error": tr("simulation.simulation_id_required", locale) }), 400 platform = data.get('platform', 'parallel') @@ -1506,18 +1829,18 @@ def start_simulation(): if max_rounds <= 0: return jsonify({ "success": False, - "error": "max_rounds 必须是正整数" + "error": tr("simulation.max_rounds_positive", locale) }), 400 except (ValueError, TypeError): return jsonify({ "success": False, - "error": "max_rounds 必须是有效的整数" + "error": tr("simulation.max_rounds_integer", locale) }), 400 if platform not in ['twitter', 'reddit', 'parallel']: return jsonify({ "success": False, - "error": f"无效的平台类型: {platform},可选: twitter/reddit/parallel" + "error": tr("simulation.invalid_platform_type", locale, platform=platform) }), 400 # 检查模拟是否已准备好 @@ -1527,7 +1850,7 @@ def start_simulation(): if not state: return jsonify({ "success": False, - "error": f"模拟不存在: {simulation_id}" + "error": tr("simulation.not_found", locale, simulation_id=simulation_id) }), 404 force_restarted = False @@ -1546,34 +1869,65 @@ def start_simulation(): # 进程确实在运行 if force: # 强制模式:停止运行中的模拟 - logger.info(f"强制模式:停止运行中的模拟 {simulation_id}") + logger.info( + tr( + "simulation.force_stop_running", + locale, + simulation_id=simulation_id, + ) + ) try: SimulationRunner.stop_simulation(simulation_id) except Exception as e: - logger.warning(f"停止模拟时出现警告: {str(e)}") + logger.warning( + tr( + "simulation.force_stop_warning", + locale, + error=str(e), + ) + ) else: return jsonify({ "success": False, - "error": f"模拟正在运行中,请先调用 /stop 接口停止,或使用 force=true 强制重新开始" + "error": tr("simulation.running_force_required", locale) }), 400 # 如果是强制模式,清理运行日志 if force: - logger.info(f"强制模式:清理模拟日志 {simulation_id}") + logger.info( + tr( + "simulation.force_cleanup_logs", + locale, + simulation_id=simulation_id, + ) + ) cleanup_result = SimulationRunner.cleanup_simulation_logs(simulation_id) if not cleanup_result.get("success"): - logger.warning(f"清理日志时出现警告: {cleanup_result.get('errors')}") + logger.warning( + tr( + "simulation.force_cleanup_warning", + locale, + errors=cleanup_result.get("errors"), + ) + ) force_restarted = True # 进程不存在或已结束,重置状态为 ready - logger.info(f"模拟 {simulation_id} 准备工作已完成,重置状态为 ready(原状态: {state.status.value})") + logger.info( + tr( + "simulation.reset_ready_after_prepare", + locale, + simulation_id=simulation_id, + status=state.status.value, + ) + ) state.status = SimulationStatus.READY manager._save_simulation_state(state) else: # 准备工作未完成 return jsonify({ "success": False, - "error": f"模拟未准备好,当前状态: {state.status.value},请先调用 /prepare 接口" + "error": tr("simulation.not_ready", locale, status=state.status.value) }), 400 # 获取图谱ID(用于图谱记忆更新) @@ -1590,10 +1944,17 @@ def start_simulation(): if not graph_id: return jsonify({ "success": False, - "error": "启用图谱记忆更新需要有效的 graph_id,请确保项目已构建图谱" + "error": tr("simulation.graph_memory_requires_graph", locale) }), 400 - logger.info(f"启用图谱记忆更新: simulation_id={simulation_id}, graph_id={graph_id}") + logger.info( + tr( + "simulation.graph_memory_enable_request", + locale, + simulation_id=simulation_id, + graph_id=graph_id, + ) + ) # 启动模拟 run_state = SimulationRunner.start_simulation( @@ -1601,7 +1962,8 @@ def start_simulation(): platform=platform, max_rounds=max_rounds, enable_graph_memory_update=enable_graph_memory_update, - graph_id=graph_id + graph_id=graph_id, + locale=locale, ) # 更新模拟状态 @@ -1628,12 +1990,7 @@ def start_simulation(): }), 400 except Exception as e: - logger.error(f"启动模拟失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_simulation_api_exception(e, get_locale(), "启动模拟失败") @simulation_bp.route('/stop', methods=['POST']) @@ -1658,12 +2015,13 @@ def stop_simulation(): """ try: data = request.get_json() or {} + locale = get_locale() simulation_id = data.get('simulation_id') if not simulation_id: return jsonify({ "success": False, - "error": "请提供 simulation_id" + "error": tr("simulation.simulation_id_required", locale) }), 400 run_state = SimulationRunner.stop_simulation(simulation_id) @@ -1687,12 +2045,7 @@ def stop_simulation(): }), 400 except Exception as e: - logger.error(f"停止模拟失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_simulation_api_exception(e, get_locale(), "停止模拟失败") # ============== 实时状态监控接口 ============== @@ -1747,23 +2100,20 @@ def get_run_status(simulation_id: str): }) except Exception as e: - logger.error(f"获取运行状态失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_simulation_api_exception(e, get_locale(), "获取运行状态失败") @simulation_bp.route('/<simulation_id>/run-status/detail', methods=['GET']) def get_run_status_detail(simulation_id: str): """ - 获取模拟运行详细状态(包含所有动作) + 获取模拟运行详细状态(默认返回有限窗口,支持增量动作拉取) 用于前端展示实时动态 Query参数: platform: 过滤平台(twitter/reddit,可选) + since: 仅返回时间戳大于等于该值的动作(可选,建议前端轮询使用) + limit: 初始加载时返回的最大动作数(默认200,最大1000) 返回: { @@ -1795,6 +2145,13 @@ def get_run_status_detail(simulation_id: str): try: run_state = SimulationRunner.get_run_state(simulation_id) platform_filter = request.args.get('platform') + since_timestamp = (request.args.get('since') or '').strip() or None + limit_arg = request.args.get('limit') + try: + requested_limit = int(limit_arg) if limit_arg else RUN_STATUS_DETAIL_DEFAULT_LIMIT + except ValueError: + requested_limit = RUN_STATUS_DETAIL_DEFAULT_LIMIT + requested_limit = max(1, min(requested_limit, RUN_STATUS_DETAIL_MAX_LIMIT)) if not run_state: return jsonify({ @@ -1804,43 +2161,61 @@ def get_run_status_detail(simulation_id: str): "runner_status": "idle", "all_actions": [], "twitter_actions": [], - "reddit_actions": [] + "reddit_actions": [], + "requested_since": since_timestamp, + "returned_actions_count": 0, + "detail_mode": "incremental" if since_timestamp else "recent_window", } }) - # 获取完整的动作列表 + # 轮询默认走增量/窗口模式,避免每次都把全量历史重新序列化到响应里。 all_actions = SimulationRunner.get_all_actions( simulation_id=simulation_id, - platform=platform_filter + platform=platform_filter, + since_timestamp=since_timestamp, + limit=None if since_timestamp else requested_limit, ) - - # 分平台获取动作 - twitter_actions = SimulationRunner.get_all_actions( - simulation_id=simulation_id, - platform="twitter" - ) if not platform_filter or platform_filter == "twitter" else [] - - reddit_actions = SimulationRunner.get_all_actions( - simulation_id=simulation_id, - platform="reddit" - ) if not platform_filter or platform_filter == "reddit" else [] + latest_action = all_actions[0] if all_actions else None + ordered_actions = list(reversed(all_actions)) + twitter_actions = [action for action in ordered_actions if action.platform == "twitter"] + reddit_actions = [action for action in ordered_actions if action.platform == "reddit"] # 获取当前轮次的动作(recent_actions 只展示最新一轮) current_round = run_state.current_round - recent_actions = SimulationRunner.get_all_actions( + recent_actions = SimulationRunner.get_actions( simulation_id=simulation_id, + limit=RUN_STATUS_DETAIL_RECENT_ACTIONS_LIMIT, platform=platform_filter, - round_num=current_round + round_num=current_round, ) if current_round > 0 else [] # 获取基础状态信息 result = run_state.to_dict() - result["all_actions"] = [a.to_dict() for a in all_actions] + result["all_actions"] = [a.to_dict() for a in ordered_actions] result["twitter_actions"] = [a.to_dict() for a in twitter_actions] result["reddit_actions"] = [a.to_dict() for a in reddit_actions] result["rounds_count"] = len(run_state.rounds) # recent_actions 只展示当前最新一轮两个平台的内容 result["recent_actions"] = [a.to_dict() for a in recent_actions] + result["requested_since"] = since_timestamp + result["returned_actions_count"] = len(ordered_actions) + result["detail_mode"] = "incremental" if since_timestamp else "recent_window" + process_alive = SimulationRunner._process_pid_is_alive(run_state.process_pid) + waiting_for_actions = ( + run_state.runner_status in {RunnerStatus.RUNNING.value, RunnerStatus.STARTING.value} + and not ordered_actions + ) + result["waiting_diagnostics"] = { + "waiting_for_actions": waiting_for_actions, + "process_alive": process_alive, + "process_pid": run_state.process_pid, + "latest_action_timestamp": latest_action.timestamp if latest_action else None, + "simulation_log_tail": ( + SimulationRunner.get_simulation_log_tail(simulation_id) + if waiting_for_actions or run_state.runner_status == RunnerStatus.FAILED.value + else "" + ), + } return jsonify({ "success": True, @@ -1848,12 +2223,7 @@ def get_run_status_detail(simulation_id: str): }) except Exception as e: - logger.error(f"获取详细状态失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_simulation_api_exception(e, get_locale(), "获取详细状态失败") @simulation_bp.route('/<simulation_id>/actions', methods=['GET']) @@ -1902,12 +2272,7 @@ def get_simulation_actions(simulation_id: str): }) except Exception as e: - logger.error(f"获取动作历史失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_simulation_api_exception(e, get_locale(), "获取动作历史失败") @simulation_bp.route('/<simulation_id>/timeline', methods=['GET']) @@ -1942,12 +2307,7 @@ def get_simulation_timeline(simulation_id: str): }) except Exception as e: - logger.error(f"获取时间线失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_simulation_api_exception(e, get_locale(), "获取时间线失败") @simulation_bp.route('/<simulation_id>/agent-stats', methods=['GET']) @@ -1969,12 +2329,7 @@ def get_agent_stats(simulation_id: str): }) except Exception as e: - logger.error(f"获取Agent统计失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_simulation_api_exception(e, get_locale(), "获取Agent统计失败") # ============== 数据库查询接口 ============== @@ -1992,14 +2347,13 @@ def get_simulation_posts(simulation_id: str): 返回帖子列表(从SQLite数据库读取) """ try: - platform = request.args.get('platform', 'reddit') + locale = get_locale() + manager = SimulationManager() + platform = _resolve_simulation_platform(manager, simulation_id, request.args.get('platform')) limit = request.args.get('limit', 50, type=int) offset = request.args.get('offset', 0, type=int) - sim_dir = os.path.join( - os.path.dirname(__file__), - f'../../uploads/simulations/{simulation_id}' - ) + sim_dir = os.path.join(Config.OASIS_SIMULATION_DATA_DIR, simulation_id) db_file = f"{platform}_simulation.db" db_path = os.path.join(sim_dir, db_file) @@ -2011,7 +2365,7 @@ def get_simulation_posts(simulation_id: str): "platform": platform, "count": 0, "posts": [], - "message": "数据库不存在,模拟可能尚未运行" + "message": tr("simulation.posts_db_missing", locale) } }) @@ -2049,40 +2403,36 @@ def get_simulation_posts(simulation_id: str): }) except Exception as e: - logger.error(f"获取帖子失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_simulation_api_exception(e, get_locale(), "获取帖子失败") @simulation_bp.route('/<simulation_id>/comments', methods=['GET']) def get_simulation_comments(simulation_id: str): """ - 获取模拟中的评论(仅Reddit) + 获取模拟中的评论 Query参数: + platform: 平台类型(twitter/reddit,未指定时按模拟配置推断) post_id: 过滤帖子ID(可选) limit: 返回数量 offset: 偏移量 """ try: + manager = SimulationManager() + platform = _resolve_simulation_platform(manager, simulation_id, request.args.get('platform')) post_id = request.args.get('post_id') limit = request.args.get('limit', 50, type=int) offset = request.args.get('offset', 0, type=int) - sim_dir = os.path.join( - os.path.dirname(__file__), - f'../../uploads/simulations/{simulation_id}' - ) + sim_dir = os.path.join(Config.OASIS_SIMULATION_DATA_DIR, simulation_id) - db_path = os.path.join(sim_dir, "reddit_simulation.db") + db_path = os.path.join(sim_dir, f"{platform}_simulation.db") if not os.path.exists(db_path): return jsonify({ "success": True, "data": { + "platform": platform, "count": 0, "comments": [] } @@ -2118,18 +2468,14 @@ def get_simulation_comments(simulation_id: str): return jsonify({ "success": True, "data": { + "platform": platform, "count": len(comments), "comments": comments } }) except Exception as e: - logger.error(f"获取评论失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_simulation_api_exception(e, get_locale(), "获取评论失败") # ============== Interview 采访接口 ============== @@ -2148,7 +2494,7 @@ def interview_agent(): "prompt": "你对这件事有什么看法?", // 必填,采访问题 "platform": "twitter", // 可选,指定平台(twitter/reddit) // 不指定时:双平台模拟同时采访两个平台 - "timeout": 60 // 可选,超时时间(秒),默认60 + "timeout": 120 // 可选,超时时间(秒),默认读取 INTERVIEW_AGENT_TIMEOUT_SECONDS } 返回(不指定platform,双平台模式): @@ -2187,47 +2533,52 @@ def interview_agent(): """ try: data = request.get_json() or {} + locale = get_locale() simulation_id = data.get('simulation_id') agent_id = data.get('agent_id') prompt = data.get('prompt') platform = data.get('platform') # 可选:twitter/reddit/None - timeout = data.get('timeout', 60) + timeout = resolve_interview_timeout( + data.get('timeout'), + Config.INTERVIEW_AGENT_TIMEOUT_SECONDS, + locale=locale, + ) if not simulation_id: return jsonify({ "success": False, - "error": "请提供 simulation_id" + "error": tr("simulation.simulation_id_required", locale) }), 400 if agent_id is None: return jsonify({ "success": False, - "error": "请提供 agent_id" + "error": tr("simulation.agent_id_required", locale) }), 400 if not prompt: return jsonify({ "success": False, - "error": "请提供 prompt(采访问题)" + "error": tr("simulation.prompt_required", locale) }), 400 # 验证platform参数 if platform and platform not in ("twitter", "reddit"): return jsonify({ "success": False, - "error": "platform 参数只能是 'twitter' 或 'reddit'" + "error": tr("simulation.platform_invalid", locale) }), 400 # 检查环境状态 if not SimulationRunner.check_env_alive(simulation_id): return jsonify({ "success": False, - "error": "模拟环境未运行或已关闭。请确保模拟已完成并进入等待命令模式。" + "error": tr("simulation.environment_not_alive", locale) }), 400 # 优化prompt,添加前缀避免Agent调用工具 - optimized_prompt = optimize_interview_prompt(prompt) + optimized_prompt = optimize_interview_prompt(prompt, locale) result = SimulationRunner.interview_agent( simulation_id=simulation_id, @@ -2251,16 +2602,11 @@ def interview_agent(): except TimeoutError as e: return jsonify({ "success": False, - "error": f"等待Interview响应超时: {str(e)}" + "error": tr("simulation.interview_timeout", locale, details=str(e)) }), 504 except Exception as e: - logger.error(f"Interview失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_simulation_api_exception(e, get_locale(), "Interview失败") @simulation_bp.route('/interview/batch', methods=['POST']) @@ -2286,7 +2632,7 @@ def interview_agents_batch(): ], "platform": "reddit", // 可选,默认平台(被每项的platform覆盖) // 不指定时:双平台模拟每个Agent同时采访两个平台 - "timeout": 120 // 可选,超时时间(秒),默认120 + "timeout": 240 // 可选,超时时间(秒),默认读取 INTERVIEW_BATCH_TIMEOUT_SECONDS } 返回: @@ -2308,30 +2654,35 @@ def interview_agents_batch(): } """ try: + locale = get_locale() data = request.get_json() or {} simulation_id = data.get('simulation_id') interviews = data.get('interviews') platform = data.get('platform') # 可选:twitter/reddit/None - timeout = data.get('timeout', 120) + timeout = resolve_interview_timeout( + data.get('timeout'), + Config.INTERVIEW_BATCH_TIMEOUT_SECONDS, + locale=locale, + ) if not simulation_id: return jsonify({ "success": False, - "error": "请提供 simulation_id" + "error": tr("simulation.simulation_id_required", locale) }), 400 if not interviews or not isinstance(interviews, list): return jsonify({ "success": False, - "error": "请提供 interviews(采访列表)" + "error": tr("simulation.interviews_required", locale) }), 400 # 验证platform参数 if platform and platform not in ("twitter", "reddit"): return jsonify({ "success": False, - "error": "platform 参数只能是 'twitter' 或 'reddit'" + "error": tr("simulation.platform_invalid", locale) }), 400 # 验证每个采访项 @@ -2339,33 +2690,36 @@ def interview_agents_batch(): if 'agent_id' not in interview: return jsonify({ "success": False, - "error": f"采访列表第{i+1}项缺少 agent_id" + "error": tr("simulation.interview_item_agent_required", locale, index=i + 1) }), 400 if 'prompt' not in interview: return jsonify({ "success": False, - "error": f"采访列表第{i+1}项缺少 prompt" + "error": tr("simulation.interview_item_prompt_required", locale, index=i + 1) }), 400 # 验证每项的platform(如果有) item_platform = interview.get('platform') if item_platform and item_platform not in ("twitter", "reddit"): return jsonify({ "success": False, - "error": f"采访列表第{i+1}项的platform只能是 'twitter' 或 'reddit'" + "error": tr("simulation.interview_item_platform_invalid", locale, index=i + 1) }), 400 # 检查环境状态 if not SimulationRunner.check_env_alive(simulation_id): return jsonify({ "success": False, - "error": "模拟环境未运行或已关闭。请确保模拟已完成并进入等待命令模式。" + "error": tr("simulation.environment_not_alive", locale) }), 400 # 优化每个采访项的prompt,添加前缀避免Agent调用工具 optimized_interviews = [] for interview in interviews: optimized_interview = interview.copy() - optimized_interview['prompt'] = optimize_interview_prompt(interview.get('prompt', '')) + optimized_interview['prompt'] = optimize_interview_prompt( + interview.get('prompt', ''), + locale, + ) optimized_interviews.append(optimized_interview) result = SimulationRunner.interview_agents_batch( @@ -2389,16 +2743,11 @@ def interview_agents_batch(): except TimeoutError as e: return jsonify({ "success": False, - "error": f"等待批量Interview响应超时: {str(e)}" + "error": tr("simulation.batch_interview_timeout", locale, details=str(e)) }), 504 except Exception as e: - logger.error(f"批量Interview失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_simulation_api_exception(e, get_locale(), "批量Interview失败") @simulation_bp.route('/interview/all', methods=['POST']) @@ -2414,7 +2763,7 @@ def interview_all_agents(): "prompt": "你对这件事整体有什么看法?", // 必填,采访问题(所有Agent使用相同问题) "platform": "reddit", // 可选,指定平台(twitter/reddit) // 不指定时:双平台模拟每个Agent同时采访两个平台 - "timeout": 180 // 可选,超时时间(秒),默认180 + "timeout": 300 // 可选,超时时间(秒),默认读取 INTERVIEW_ALL_TIMEOUT_SECONDS } 返回: @@ -2435,41 +2784,46 @@ def interview_all_agents(): } """ try: + locale = get_locale() data = request.get_json() or {} simulation_id = data.get('simulation_id') prompt = data.get('prompt') platform = data.get('platform') # 可选:twitter/reddit/None - timeout = data.get('timeout', 180) + timeout = resolve_interview_timeout( + data.get('timeout'), + Config.INTERVIEW_ALL_TIMEOUT_SECONDS, + locale=locale, + ) if not simulation_id: return jsonify({ "success": False, - "error": "请提供 simulation_id" + "error": tr("simulation.simulation_id_required", locale) }), 400 if not prompt: return jsonify({ "success": False, - "error": "请提供 prompt(采访问题)" + "error": tr("simulation.prompt_required", locale) }), 400 # 验证platform参数 if platform and platform not in ("twitter", "reddit"): return jsonify({ "success": False, - "error": "platform 参数只能是 'twitter' 或 'reddit'" + "error": tr("simulation.platform_invalid", locale) }), 400 # 检查环境状态 if not SimulationRunner.check_env_alive(simulation_id): return jsonify({ "success": False, - "error": "模拟环境未运行或已关闭。请确保模拟已完成并进入等待命令模式。" + "error": tr("simulation.environment_not_alive", locale) }), 400 # 优化prompt,添加前缀避免Agent调用工具 - optimized_prompt = optimize_interview_prompt(prompt) + optimized_prompt = optimize_interview_prompt(prompt, locale) result = SimulationRunner.interview_all_agents( simulation_id=simulation_id, @@ -2492,16 +2846,11 @@ def interview_all_agents(): except TimeoutError as e: return jsonify({ "success": False, - "error": f"等待全局Interview响应超时: {str(e)}" + "error": tr("simulation.all_interview_timeout", locale, details=str(e)) }), 504 except Exception as e: - logger.error(f"全局Interview失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_simulation_api_exception(e, get_locale(), "全局Interview失败") @simulation_bp.route('/interview/history', methods=['POST']) @@ -2540,6 +2889,7 @@ def get_interview_history(): """ try: data = request.get_json() or {} + locale = get_locale() simulation_id = data.get('simulation_id') platform = data.get('platform') # 不指定则返回两个平台的历史 @@ -2549,7 +2899,7 @@ def get_interview_history(): if not simulation_id: return jsonify({ "success": False, - "error": "请提供 simulation_id" + "error": tr("simulation.simulation_id_required", locale) }), 400 history = SimulationRunner.get_interview_history( @@ -2568,12 +2918,7 @@ def get_interview_history(): }) except Exception as e: - logger.error(f"获取Interview历史失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_simulation_api_exception(e, get_locale(), "获取Interview历史失败") @simulation_bp.route('/env-status', methods=['POST']) @@ -2602,13 +2947,14 @@ def get_env_status(): """ try: data = request.get_json() or {} + locale = get_locale() simulation_id = data.get('simulation_id') if not simulation_id: return jsonify({ "success": False, - "error": "请提供 simulation_id" + "error": tr("simulation.simulation_id_required", locale) }), 400 env_alive = SimulationRunner.check_env_alive(simulation_id) @@ -2617,9 +2963,9 @@ def get_env_status(): env_status = SimulationRunner.get_env_status_detail(simulation_id) if env_alive: - message = "环境正在运行,可以接收Interview命令" + message = tr("simulation.env_running", locale) else: - message = "环境未运行或已关闭" + message = tr("simulation.env_closed", locale) return jsonify({ "success": True, @@ -2633,12 +2979,7 @@ def get_env_status(): }) except Exception as e: - logger.error(f"获取环境状态失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_simulation_api_exception(e, get_locale(), "获取环境状态失败") @simulation_bp.route('/close-env', methods=['POST']) @@ -2669,6 +3010,7 @@ def close_simulation_env(): """ try: data = request.get_json() or {} + locale = get_locale() simulation_id = data.get('simulation_id') timeout = data.get('timeout', 30) @@ -2676,12 +3018,13 @@ def close_simulation_env(): if not simulation_id: return jsonify({ "success": False, - "error": "请提供 simulation_id" + "error": tr("simulation.simulation_id_required", locale) }), 400 result = SimulationRunner.close_simulation_env( simulation_id=simulation_id, - timeout=timeout + timeout=timeout, + locale=locale, ) # 更新模拟状态 @@ -2703,9 +3046,4 @@ def close_simulation_env(): }), 400 except Exception as e: - logger.error(f"关闭环境失败: {str(e)}") - return jsonify({ - "success": False, - "error": str(e), - "traceback": traceback.format_exc() - }), 500 + return _handle_simulation_api_exception(e, get_locale(), "关闭环境失败") diff --git a/backend/app/config.py b/backend/app/config.py index 953dfa50..efdc3127 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -4,7 +4,21 @@ """ import os -from dotenv import load_dotenv +import secrets +from dataclasses import dataclass, field +from typing import Any +from urllib.parse import urlparse + +try: + from dotenv import load_dotenv +except ImportError: # pragma: no cover - allows config preflight in minimal shells + def load_dotenv(*_args, **_kwargs): + return False + +try: + from .i18n import tr +except ImportError: # pragma: no cover - compatibility for direct module loading in tests + from app.i18n import tr # 加载项目根目录的 .env 文件 # 路径: MiroFish/.env (相对于 backend/app/config.py) @@ -17,23 +31,159 @@ load_dotenv(override=True) +def _env(*names, default=None): + """Return the first configured environment variable from the provided aliases.""" + for name in names: + value = os.environ.get(name) + if value not in (None, ''): + return value + return default + + +def _configured_env_name(*names): + """Return the first configured environment variable name from the provided aliases.""" + for name in names: + value = os.environ.get(name) + if value not in (None, ''): + return name + return None + + +def _configured_env_entries(*names): + """Return configured environment variable name/value pairs in priority order.""" + entries = [] + for name in names: + value = os.environ.get(name) + if value not in (None, ''): + entries.append((name, value)) + return entries + + +def _int_env(name, default): + """Parse integer environment variables without crashing module import.""" + raw_value = os.environ.get(name) + if raw_value in (None, ''): + return default + try: + return int(raw_value) + except (TypeError, ValueError): + return default + + +def _float_env(name, default): + """Parse float environment variables without crashing module import.""" + raw_value = os.environ.get(name) + if raw_value in (None, ''): + return default + try: + return float(raw_value) + except (TypeError, ValueError): + return default + + +def _csv_env(name, default=None): + """Parse comma-separated environment variables into a normalized list.""" + raw_value = os.environ.get(name) + if raw_value in (None, ''): + return list(default or []) + return [item.strip() for item in raw_value.split(',') if item.strip()] + + +def _bool_env(name, default=False): + """Parse boolean environment variables using common truthy spellings.""" + raw_value = os.environ.get(name) + if raw_value in (None, ''): + return default + return raw_value.strip().lower() in {'1', 'true', 'yes', 'on'} + + +def _load_secret_key(): + """Use the configured SECRET_KEY or generate an ephemeral fallback.""" + configured = os.environ.get('SECRET_KEY') + if configured not in (None, ''): + return configured, False + return secrets.token_hex(32), True + + +def _default_cors_origins(): + """Keep default CORS permissive for local development without allowing every origin.""" + return [ + 'http://localhost:3000', + 'http://127.0.0.1:3000', + 'http://localhost:4173', + 'http://127.0.0.1:4173', + 'http://localhost:5173', + 'http://127.0.0.1:5173', + ] + + +@dataclass +class ConfigValidationResult: + """Structured config validation output.""" + + errors: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + info: list[str] = field(default_factory=list) + + @property + def is_valid(self) -> bool: + return not self.errors + + def add_error(self, message: str) -> None: + self.errors.append(message) + + def add_warning(self, message: str) -> None: + self.warnings.append(message) + + def add_info(self, message: str) -> None: + self.info.append(message) + + def to_dict(self) -> dict[str, Any]: + return { + 'is_valid': self.is_valid, + 'errors': self.errors, + 'warnings': self.warnings, + 'info': self.info, + 'error_count': len(self.errors), + 'warning_count': len(self.warnings), + } + + class Config: """Flask配置类""" # Flask配置 - SECRET_KEY = os.environ.get('SECRET_KEY', 'mirofish-secret-key') - DEBUG = os.environ.get('FLASK_DEBUG', 'True').lower() == 'true' + SECRET_KEY, SECRET_KEY_IS_GENERATED = _load_secret_key() + DEBUG = _bool_env('FLASK_DEBUG', default=False) + CORS_ALLOWED_ORIGINS = _csv_env('CORS_ALLOWED_ORIGINS', default=_default_cors_origins()) + CORS_ALLOW_METHODS = _csv_env( + 'CORS_ALLOW_METHODS', + default=['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + ) + CORS_ALLOW_HEADERS = _csv_env( + 'CORS_ALLOW_HEADERS', + default=['Content-Type', 'Authorization', 'X-Locale'], + ) # JSON配置 - 禁用ASCII转义,让中文直接显示(而不是 \uXXXX 格式) JSON_AS_ASCII = False # LLM配置(统一使用OpenAI格式) - LLM_API_KEY = os.environ.get('LLM_API_KEY') - LLM_BASE_URL = os.environ.get('LLM_BASE_URL', 'https://api.openai.com/v1') - LLM_MODEL_NAME = os.environ.get('LLM_MODEL_NAME', 'gpt-4o-mini') + LLM_API_KEY = _env('LLM_API_KEY', 'OPENAI_API_KEY') + LLM_BASE_URL = _env( + 'LLM_BASE_URL', + 'OPENAI_BASE_URL', + 'OPENAI_API_BASE_URL', + default='https://api.openai.com/v1', + ) + LLM_MODEL_NAME = _env('LLM_MODEL_NAME', 'OPENAI_MODEL', default='gpt-4o-mini') + LLM_MAX_TOKENS = _int_env('LLM_MAX_TOKENS', 4096) # Zep配置 ZEP_API_KEY = os.environ.get('ZEP_API_KEY') + ZEP_RETRY_MAX_ATTEMPTS = _int_env('ZEP_RETRY_MAX_ATTEMPTS', 3) + ZEP_RETRY_BASE_DELAY_SECONDS = _float_env('ZEP_RETRY_BASE_DELAY_SECONDS', 2.0) + ZEP_RETRY_MAX_DELAY_SECONDS = _float_env('ZEP_RETRY_MAX_DELAY_SECONDS', 60.0) # 文件上传配置 MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50MB @@ -45,8 +195,11 @@ class Config: DEFAULT_CHUNK_OVERLAP = 50 # 默认重叠大小 # OASIS模拟配置 - OASIS_DEFAULT_MAX_ROUNDS = int(os.environ.get('OASIS_DEFAULT_MAX_ROUNDS', '10')) + OASIS_DEFAULT_MAX_ROUNDS = _int_env('OASIS_DEFAULT_MAX_ROUNDS', 10) OASIS_SIMULATION_DATA_DIR = os.path.join(os.path.dirname(__file__), '../uploads/simulations') + INTERVIEW_AGENT_TIMEOUT_SECONDS = _float_env('INTERVIEW_AGENT_TIMEOUT_SECONDS', 120.0) + INTERVIEW_BATCH_TIMEOUT_SECONDS = _float_env('INTERVIEW_BATCH_TIMEOUT_SECONDS', 240.0) + INTERVIEW_ALL_TIMEOUT_SECONDS = _float_env('INTERVIEW_ALL_TIMEOUT_SECONDS', 300.0) # OASIS平台可用动作配置 OASIS_TWITTER_ACTIONS = [ @@ -59,17 +212,223 @@ class Config: ] # Report Agent配置 - REPORT_AGENT_MAX_TOOL_CALLS = int(os.environ.get('REPORT_AGENT_MAX_TOOL_CALLS', '5')) - REPORT_AGENT_MAX_REFLECTION_ROUNDS = int(os.environ.get('REPORT_AGENT_MAX_REFLECTION_ROUNDS', '2')) - REPORT_AGENT_TEMPERATURE = float(os.environ.get('REPORT_AGENT_TEMPERATURE', '0.5')) - + REPORT_AGENT_MAX_TOOL_CALLS = _int_env('REPORT_AGENT_MAX_TOOL_CALLS', 5) + REPORT_AGENT_MAX_REFLECTION_ROUNDS = _int_env('REPORT_AGENT_MAX_REFLECTION_ROUNDS', 2) + REPORT_AGENT_TEMPERATURE = _float_env('REPORT_AGENT_TEMPERATURE', 0.5) + LLM_BASE_URL_ENV_NAMES = ('LLM_BASE_URL', 'OPENAI_BASE_URL', 'OPENAI_API_BASE_URL') + @classmethod - def validate(cls): + def validate(cls, locale='zh'): """验证必要配置""" - errors = [] + return cls.validate_comprehensive(locale=locale).errors + + @classmethod + def validate_comprehensive(cls, locale='zh'): + """Validate configuration without changing startup behavior.""" + result = ConfigValidationResult() + if not cls.LLM_API_KEY: - errors.append("LLM_API_KEY 未配置") + result.add_error(tr("config.key_missing", locale, name="LLM_API_KEY / OPENAI_API_KEY")) if not cls.ZEP_API_KEY: - errors.append("ZEP_API_KEY 未配置") - return errors + result.add_error(tr("config.key_missing", locale, name="ZEP_API_KEY")) + + cls._validate_url( + result, + "LLM_BASE_URL / OPENAI_BASE_URL / OPENAI_API_BASE_URL", + cls.LLM_BASE_URL, + locale=locale, + ) + base_url_conflict = cls._get_alias_conflict(*cls.LLM_BASE_URL_ENV_NAMES) + if base_url_conflict: + result.add_warning( + tr( + "config.alias_conflict", + locale, + group=" / ".join(cls.LLM_BASE_URL_ENV_NAMES), + selected=base_url_conflict["selected_env"], + value=base_url_conflict["selected_value"], + ) + ) + cls._validate_numeric_env(result, "LLM_MAX_TOKENS", minimum=1, locale=locale) + cls._validate_numeric_env(result, "OASIS_DEFAULT_MAX_ROUNDS", minimum=1, locale=locale) + cls._validate_numeric_env(result, "INTERVIEW_AGENT_TIMEOUT_SECONDS", minimum=1, parser=float, locale=locale) + cls._validate_numeric_env(result, "INTERVIEW_BATCH_TIMEOUT_SECONDS", minimum=1, parser=float, locale=locale) + cls._validate_numeric_env(result, "INTERVIEW_ALL_TIMEOUT_SECONDS", minimum=1, parser=float, locale=locale) + cls._validate_numeric_env(result, "REPORT_AGENT_MAX_TOOL_CALLS", minimum=1, locale=locale) + cls._validate_numeric_env(result, "REPORT_AGENT_MAX_REFLECTION_ROUNDS", minimum=0, locale=locale) + cls._validate_numeric_env( + result, + "REPORT_AGENT_TEMPERATURE", + minimum=0, + maximum=2, + parser=float, + locale=locale, + ) + cls._validate_numeric_env(result, "ZEP_RETRY_MAX_ATTEMPTS", minimum=1, locale=locale) + cls._validate_numeric_env( + result, + "ZEP_RETRY_BASE_DELAY_SECONDS", + minimum=0, + parser=float, + locale=locale, + ) + cls._validate_numeric_env( + result, + "ZEP_RETRY_MAX_DELAY_SECONDS", + minimum=0, + parser=float, + locale=locale, + ) + + if cls.DEBUG: + result.add_warning(tr("config.debug_warning", locale)) + if cls.SECRET_KEY_IS_GENERATED: + result.add_warning(tr("config.secret_key_warning", locale)) + if not os.path.isdir(cls.UPLOAD_FOLDER): + result.add_info(tr("config.upload_folder_info", locale, path=cls.UPLOAD_FOLDER)) + if cls.LLM_MODEL_NAME: + result.add_info(tr("config.model_info", locale, model=cls.LLM_MODEL_NAME)) + + return result + + @classmethod + def get_config_summary(cls): + """Return a non-sensitive config snapshot for diagnostics.""" + llm_api_key_source = _configured_env_name('LLM_API_KEY', 'OPENAI_API_KEY') + llm_base_url_source = _configured_env_name(*cls.LLM_BASE_URL_ENV_NAMES) + llm_model_source = _configured_env_name('LLM_MODEL_NAME', 'OPENAI_MODEL') + base_url_conflict = cls._get_alias_conflict(*cls.LLM_BASE_URL_ENV_NAMES) + llm_core_ready = bool(cls.LLM_API_KEY and cls.LLM_BASE_URL) + zep_ready = bool(cls.ZEP_API_KEY) + + return { + 'cors': { + 'allowed_origins': cls.CORS_ALLOWED_ORIGINS, + 'allow_methods': cls.CORS_ALLOW_METHODS, + 'allow_headers': cls.CORS_ALLOW_HEADERS, + }, + 'llm': { + 'backend_mode': 'openai_compatible', + 'base_url': cls.LLM_BASE_URL, + 'model': cls.LLM_MODEL_NAME, + 'max_tokens': cls.LLM_MAX_TOKENS, + 'configured': bool(cls.LLM_API_KEY), + 'sources': { + 'api_key_env': llm_api_key_source, + 'base_url_env': llm_base_url_source, + 'model_env': llm_model_source, + 'base_url_conflict': base_url_conflict, + 'uses_project_aliases': any( + source and source.startswith('LLM_') + for source in (llm_api_key_source, llm_base_url_source, llm_model_source) + ), + 'uses_openai_aliases': any( + source and source.startswith('OPENAI_') + for source in (llm_api_key_source, llm_base_url_source, llm_model_source) + ), + }, + }, + 'capabilities': { + 'direct_llm': { + 'ready': llm_core_ready, + }, + 'graph_build': { + 'ready': llm_core_ready and zep_ready, + 'requires_zep': True, + }, + 'graph_report_tools': { + 'ready': llm_core_ready and zep_ready, + 'requires_zep': True, + }, + 'existing_simulation_interaction': { + 'ready': llm_core_ready, + 'requires_existing_simulation': True, + }, + }, + 'zep': { + 'configured': zep_ready, + 'retry_max_attempts': cls.ZEP_RETRY_MAX_ATTEMPTS, + 'retry_base_delay_seconds': cls.ZEP_RETRY_BASE_DELAY_SECONDS, + }, + 'simulation': { + 'default_max_rounds': cls.OASIS_DEFAULT_MAX_ROUNDS, + 'data_dir': cls.OASIS_SIMULATION_DATA_DIR, + 'interview_timeouts': { + 'single_seconds': cls.INTERVIEW_AGENT_TIMEOUT_SECONDS, + 'batch_seconds': cls.INTERVIEW_BATCH_TIMEOUT_SECONDS, + 'all_seconds': cls.INTERVIEW_ALL_TIMEOUT_SECONDS, + }, + }, + 'report_agent': { + 'max_tool_calls': cls.REPORT_AGENT_MAX_TOOL_CALLS, + 'max_reflection_rounds': cls.REPORT_AGENT_MAX_REFLECTION_ROUNDS, + 'temperature': cls.REPORT_AGENT_TEMPERATURE, + }, + 'debug': cls.DEBUG, + 'security': { + 'secret_key_source': 'generated' if cls.SECRET_KEY_IS_GENERATED else 'env', + }, + } + + @classmethod + def get_cors_resources(cls): + """Return Flask-CORS resource options from env-backed config.""" + return { + 'origins': cls.CORS_ALLOWED_ORIGINS or ['*'], + 'methods': cls.CORS_ALLOW_METHODS, + 'allow_headers': cls.CORS_ALLOW_HEADERS, + } + + @classmethod + def _validate_url(cls, result, name, value, locale='zh'): + if not value: + result.add_error(tr("config.key_missing", locale, name=name)) + return + parsed = urlparse(value) + if parsed.scheme not in {'http', 'https'}: + result.add_error(tr("config.url_invalid_scheme", locale, name=name, value=value)) + return + if not parsed.netloc: + result.add_error(tr("config.url_missing_host", locale, name=name, value=value)) + + @classmethod + def _validate_numeric_env(cls, result, name, minimum=None, maximum=None, parser=int, locale='zh'): + raw_value = os.environ.get(name) + if raw_value in (None, ''): + return + try: + value = parser(raw_value) + except (TypeError, ValueError): + result.add_error(tr("config.numeric_invalid", locale, name=name, value=raw_value)) + return + if minimum is not None and value < minimum: + result.add_error(tr("config.numeric_min", locale, name=name, minimum=minimum, value=raw_value)) + if maximum is not None and value > maximum: + result.add_error(tr("config.numeric_max", locale, name=name, maximum=maximum, value=raw_value)) + + @classmethod + def _get_alias_conflict(cls, *names): + entries = _configured_env_entries(*names) + if len(entries) < 2: + return None + + distinct_values = {value for _, value in entries} + if len(distinct_values) <= 1: + return None + + selected_env = entries[0][0] + selected_value = entries[0][1] + return { + 'has_conflict': True, + 'selected_env': selected_env, + 'selected_value': selected_value, + 'configured_envs': [ + {'name': name, 'value': value} + for name, value in entries + ], + } + +def validate_on_startup(): + """Compatibility helper for explicit startup validation hooks.""" + return Config.validate_comprehensive().is_valid diff --git a/backend/app/i18n.py b/backend/app/i18n.py new file mode 100644 index 00000000..b85fa2e5 --- /dev/null +++ b/backend/app/i18n.py @@ -0,0 +1,1257 @@ +"""Minimal backend i18n helpers for deterministic API messages.""" + +from __future__ import annotations + +try: + from flask import has_request_context, request +except ImportError: # pragma: no cover - allows config preflight without Flask installed + def has_request_context() -> bool: + return False + + request = None + + +DEFAULT_LOCALE = "zh" + +TRANSLATIONS = { + "api.backend_config_incomplete": { + "zh": "后端配置不完整: {details}", + "en": "Backend configuration is incomplete: {details}", + }, + "config.key_missing": { + "zh": "{name} 未配置", + "en": "{name} is not configured", + }, + "config.llm_key_missing": { + "zh": "LLM_API_KEY / OPENAI_API_KEY 未配置", + "en": "LLM_API_KEY / OPENAI_API_KEY is not configured", + }, + "config.url_invalid_scheme": { + "zh": "{name} 必须使用 http/https: {value}", + "en": "{name} must use http/https: {value}", + }, + "config.url_missing_host": { + "zh": "{name} 缺少主机名: {value}", + "en": "{name} is missing a hostname: {value}", + }, + "config.numeric_invalid": { + "zh": "{name} 必须是合法数字,当前值: {value}", + "en": "{name} must be a valid number, current value: {value}", + }, + "config.numeric_min": { + "zh": "{name} 必须 >= {minimum},当前值: {value}", + "en": "{name} must be >= {minimum}, current value: {value}", + }, + "config.numeric_max": { + "zh": "{name} 必须 <= {maximum},当前值: {value}", + "en": "{name} must be <= {maximum}, current value: {value}", + }, + "config.debug_warning": { + "zh": "FLASK_DEBUG=True; 不建议在生产环境启用 DEBUG", + "en": "FLASK_DEBUG=True; DEBUG should not be enabled in production", + }, + "config.secret_key_warning": { + "zh": "SECRET_KEY 未配置;当前进程正在使用临时随机值,生产环境应显式配置", + "en": "SECRET_KEY is not configured; a temporary random key is being used for this process, so production deployments should set it explicitly", + }, + "config.upload_folder_info": { + "zh": "UPLOAD_FOLDER 尚不存在,将在运行时按需创建: {path}", + "en": "UPLOAD_FOLDER does not exist yet and will be created on demand at runtime: {path}", + }, + "config.model_info": { + "zh": "LLM_MODEL_NAME={model}", + "en": "LLM_MODEL_NAME={model}", + }, + "config.alias_conflict": { + "zh": "{group} 同时配置了不同的值;当前将优先使用 {selected}={value}", + "en": "{group} is configured with conflicting values; {selected}={value} will take precedence", + }, + "llm.json_mode_retry": { + "zh": "LLM 后端不支持 response_format=json_object;将改为不使用 JSON 模式重试", + "en": "LLM backend rejected response_format=json_object; retrying without JSON mode", + }, + "startup.config_error_header": { + "zh": "配置错误:", + "en": "Configuration errors:", + }, + "startup.config_hint": { + "zh": "请检查 .env 文件中的配置", + "en": "Check the configuration in the .env file", + }, + "app.starting": { + "zh": "MiroFish Backend 启动中...", + "en": "MiroFish Backend is starting...", + }, + "app.cleanup_registered": { + "zh": "已注册模拟进程清理函数", + "en": "Registered the simulation process cleanup hook", + }, + "app.request": { + "zh": "请求: {method} {path}", + "en": "Request: {method} {path}", + }, + "app.request_body": { + "zh": "请求体: {body}", + "en": "Request body: {body}", + }, + "app.response": { + "zh": "响应: {status_code}", + "en": "Response: {status_code}", + }, + "app.started": { + "zh": "MiroFish Backend 启动完成", + "en": "MiroFish Backend startup completed", + }, + "retry.sync_failed_final": { + "zh": "函数 {func_name} 在 {max_retries} 次重试后仍失败: {error}", + "en": "Function {func_name} still failed after {max_retries} retries: {error}", + }, + "retry.sync_failed_attempt": { + "zh": "函数 {func_name} 第 {attempt} 次尝试失败: {error}, {delay:.1f}秒后重试...", + "en": "Function {func_name} failed on attempt {attempt}: {error}, retrying in {delay:.1f}s...", + }, + "retry.async_failed_final": { + "zh": "异步函数 {func_name} 在 {max_retries} 次重试后仍失败: {error}", + "en": "Async function {func_name} still failed after {max_retries} retries: {error}", + }, + "retry.async_failed_attempt": { + "zh": "异步函数 {func_name} 第 {attempt} 次尝试失败: {error}, {delay:.1f}秒后重试...", + "en": "Async function {func_name} failed on attempt {attempt}: {error}, retrying in {delay:.1f}s...", + }, + "retry.api_failed_final": { + "zh": "API调用在 {max_retries} 次重试后仍失败: {error}", + "en": "API call still failed after {max_retries} retries: {error}", + }, + "retry.api_failed_attempt": { + "zh": "API调用第 {attempt} 次尝试失败: {error}, {delay:.1f}秒后重试...", + "en": "API call failed on attempt {attempt}: {error}, retrying in {delay:.1f}s...", + }, + "zep.paging_failed_attempt": { + "zh": "Zep {page_description} 第 {attempt} 次尝试失败: {error}, {delay:.1f}秒后重试...", + "en": "Zep {page_description} failed on attempt {attempt}: {error}, retrying in {delay:.1f}s...", + }, + "zep.paging_failed_final": { + "zh": "Zep {page_description} 在 {max_retries} 次尝试后仍失败: {error}", + "en": "Zep {page_description} still failed after {max_retries} attempts: {error}", + }, + "zep.paging_node_limit": { + "zh": "节点数量达到上限 ({max_items}),停止图谱 {graph_id} 的分页读取", + "en": "Node count reached the limit ({max_items}); stopping pagination for graph {graph_id}", + }, + "zep.paging_node_missing_uuid": { + "zh": "节点缺少 uuid 字段,在读取 {count} 个节点后停止分页", + "en": "A node is missing the uuid field; stopping pagination after reading {count} nodes", + }, + "zep.paging_edge_missing_uuid": { + "zh": "边缺少 uuid 字段,在读取 {count} 条边后停止分页", + "en": "An edge is missing the uuid field; stopping pagination after reading {count} edges", + }, + "retry.batch_item_failed": { + "zh": "处理第 {index} 项失败: {error}", + "en": "Failed to process item {index}: {error}", + }, + "llm.invalid_json": { + "zh": "LLM返回的JSON格式无效: {payload}", + "en": "The LLM returned invalid JSON: {payload}", + }, + "file.not_found": { + "zh": "文件不存在: {path}", + "en": "File not found: {path}", + }, + "file.unsupported_type": { + "zh": "不支持的文件格式: {suffix}", + "en": "Unsupported file format: {suffix}", + }, + "file.unhandled_type": { + "zh": "无法处理的文件格式: {suffix}", + "en": "Unhandled file format: {suffix}", + }, + "file.pdf_dependency_missing": { + "zh": "缺少 PDF 解析依赖 PyMuPDF,请先执行 `pip install PyMuPDF`", + "en": "Missing PDF parsing dependency PyMuPDF. Install it with `pip install PyMuPDF` first.", + }, + "file.multi_doc_header": { + "zh": "=== 文档 {index}: {filename} ===", + "en": "=== Document {index}: {filename} ===", + }, + "file.multi_doc_failed_header": { + "zh": "=== 文档 {index}: {filename} (提取失败: {details}) ===", + "en": "=== Document {index}: {filename} (extraction failed: {details}) ===", + }, + "task.completed": { + "zh": "任务完成", + "en": "Task completed", + }, + "task.failed": { + "zh": "任务失败", + "en": "Task failed", + }, + "graph.project_not_found": { + "zh": "项目不存在: {project_id}", + "en": "Project not found: {project_id}", + }, + "graph.project_delete_failed": { + "zh": "项目不存在或删除失败: {project_id}", + "en": "Project not found or could not be deleted: {project_id}", + }, + "graph.project_deleted": { + "zh": "项目已删除: {project_id}", + "en": "Project deleted: {project_id}", + }, + "graph.project_reset": { + "zh": "项目已重置: {project_id}", + "en": "Project reset: {project_id}", + }, + "graph.simulation_requirement_required": { + "zh": "请提供模拟需求描述 (simulation_requirement)", + "en": "Please provide simulation_requirement", + }, + "graph.upload_files_required": { + "zh": "请至少上传一个文档文件", + "en": "Please upload at least one document file", + }, + "graph.document_processing_failed": { + "zh": "{count} 个文档处理失败,请根据返回的文件错误信息修正后重试", + "en": "{count} document(s) could not be processed. Fix the reported file issues and retry.", + }, + "graph.unsupported_file_type": { + "zh": "文件 {filename} 的格式不受支持。当前仅支持: {extensions}", + "en": "File {filename} is not supported. Supported formats: {extensions}", + }, + "graph.document_parse_failed": { + "zh": "文件 {filename} 解析失败: {details}", + "en": "Failed to parse file {filename}: {details}", + }, + "graph.document_parse_failed_log": { + "zh": "文档解析失败 {filename}: {details}", + "en": "Document parsing failed for {filename}: {details}", + }, + "graph.document_empty_after_parse": { + "zh": "文件 {filename} 未提取到可用文本内容", + "en": "File {filename} did not yield any usable text", + }, + "graph.no_processed_documents": { + "zh": "没有成功处理任何文档,请检查文件格式", + "en": "No documents were processed successfully. Check the file format.", + }, + "graph.config_error": { + "zh": "配置错误: {details}", + "en": "Configuration error: {details}", + }, + "graph.project_id_required": { + "zh": "请提供 project_id", + "en": "Please provide project_id", + }, + "graph.ontology_required": { + "zh": "项目尚未生成本体,请先调用 /ontology/generate", + "en": "The project ontology has not been generated yet. Call /ontology/generate first.", + }, + "graph.build_in_progress": { + "zh": "图谱正在构建中,请勿重复提交。如需强制重建,请添加 force: true", + "en": "The graph is already building. Do not submit again unless you set force: true.", + }, + "graph.extracted_text_missing": { + "zh": "未找到提取的文本内容", + "en": "No extracted text content was found", + }, + "graph.ontology_missing": { + "zh": "未找到本体定义", + "en": "No ontology definition was found", + }, + "graph.task_not_found": { + "zh": "任务不存在: {task_id}", + "en": "Task not found: {task_id}", + }, + "graph.zep_key_missing": { + "zh": "ZEP_API_KEY未配置", + "en": "ZEP_API_KEY is not configured", + }, + "graph.zep_auth_failed": { + "zh": "Zep 认证失败,请检查 ZEP_API_KEY 是否有效并确认其对应当前的 Zep Cloud 项目。", + "en": "Zep authentication failed. Check that ZEP_API_KEY is valid and belongs to the current Zep Cloud project.", + }, + "graph.zep_permission_denied": { + "zh": "Zep 权限不足,请确认当前 API Key 拥有访问目标图谱的权限。", + "en": "Zep permission denied. Confirm that the current API key can access the target graph.", + }, + "zep.reader_retry_failed_attempt": { + "zh": "Zep {operation_name} 第 {attempt} 次尝试失败: {error}, {delay:.1f}秒后重试...", + "en": "Zep {operation_name} failed on attempt {attempt}: {error}, retrying in {delay:.1f}s...", + }, + "zep.reader_retry_failed_final": { + "zh": "Zep {operation_name} 在 {max_retries} 次尝试后仍失败: {error}", + "en": "Zep {operation_name} still failed after {max_retries} retries: {error}", + }, + "zep.reader_get_all_nodes_start": { + "zh": "获取图谱 {graph_id} 的所有节点...", + "en": "Fetching all nodes for graph {graph_id}...", + }, + "zep.reader_get_all_nodes_done": { + "zh": "共获取 {count} 个节点", + "en": "Fetched {count} node(s) in total", + }, + "zep.reader_get_all_edges_start": { + "zh": "获取图谱 {graph_id} 的所有边...", + "en": "Fetching all edges for graph {graph_id}...", + }, + "zep.reader_get_all_edges_done": { + "zh": "共获取 {count} 条边", + "en": "Fetched {count} edge(s) in total", + }, + "zep.reader_get_node_edges_failed": { + "zh": "获取节点 {node_uuid} 的边失败: {error}", + "en": "Failed to fetch edges for node {node_uuid}: {error}", + }, + "zep.reader_get_node_edges_operation": { + "zh": "获取节点边(node={node_uuid})", + "en": "fetch node edges (node={node_uuid})", + }, + "zep.reader_get_node_detail_operation": { + "zh": "获取节点详情(uuid={entity_uuid})", + "en": "fetch node details (uuid={entity_uuid})", + }, + "zep.reader_filter_start": { + "zh": "开始筛选图谱 {graph_id} 的实体...", + "en": "Starting entity filtering for graph {graph_id}...", + }, + "zep.reader_filter_deduped": { + "zh": "实体别名去重完成: 合并了 {count} 个重复实体候选", + "en": "Duplicate entity alias collapse completed: merged {count} duplicate candidate(s)", + }, + "zep.reader_filter_done": { + "zh": "筛选完成: 总节点 {total_count}, 符合条件 {filtered_count}, 实体类型: {entity_types}", + "en": "Entity filtering completed: total nodes {total_count}, matched {filtered_count}, entity types: {entity_types}", + }, + "zep.reader_get_entity_failed": { + "zh": "获取实体 {entity_uuid} 失败: {error}", + "en": "Failed to fetch entity {entity_uuid}: {error}", + }, + "simulation.entity_not_found": { + "zh": "实体不存在: {entity_uuid}", + "en": "Entity not found: {entity_uuid}", + }, + "graph.graph_deleted": { + "zh": "图谱已删除: {graph_id}", + "en": "Graph deleted: {graph_id}", + }, + "graph.build_started": { + "zh": "图谱构建任务已启动,请通过 /task/{task_id} 查询进度", + "en": "The graph build task has started. Query /task/{task_id} for progress.", + }, + "graph.ontology_log_started": { + "zh": "=== 开始生成本体定义 ===", + "en": "=== Starting ontology generation ===", + }, + "graph.ontology_log_project_name": { + "zh": "项目名称: {project_name}", + "en": "Project name: {project_name}", + }, + "graph.ontology_log_requirement": { + "zh": "模拟需求: {requirement}", + "en": "Simulation requirement: {requirement}", + }, + "graph.project_created_log": { + "zh": "创建项目: {project_id}", + "en": "Created project: {project_id}", + }, + "graph.text_extraction_completed_log": { + "zh": "文本提取完成,共 {total_chars} 字符", + "en": "Text extraction completed with {total_chars} character(s)", + }, + "graph.ontology_call_started_log": { + "zh": "调用 LLM 生成本体定义...", + "en": "Calling the LLM to generate the ontology...", + }, + "graph.ontology_generation_completed_log": { + "zh": "本体生成完成: {entity_count} 个实体类型, {edge_count} 个关系类型", + "en": "Ontology generation completed: {entity_count} entity type(s), {edge_count} edge type(s)", + }, + "graph.ontology_log_completed": { + "zh": "=== 本体生成完成 === 项目ID: {project_id}", + "en": "=== Ontology generation completed === project_id: {project_id}", + }, + "graph.build_task_type": { + "zh": "构建图谱: {graph_name}", + "en": "Build graph: {graph_name}", + }, + "graph.build_log_started": { + "zh": "=== 开始构建图谱 ===", + "en": "=== Starting graph build ===", + }, + "graph.build_log_request_params": { + "zh": "请求参数: project_id={project_id}", + "en": "Request params: project_id={project_id}", + }, + "graph.build_task_created_log": { + "zh": "创建图谱构建任务: task_id={task_id}, project_id={project_id}", + "en": "Created graph build task: task_id={task_id}, project_id={project_id}", + }, + "graph.build_service_initializing": { + "zh": "初始化图谱构建服务...", + "en": "Initializing the graph build service...", + }, + "graph.build_chunking": { + "zh": "文本分块中...", + "en": "Splitting text into chunks...", + }, + "graph.build_creating_graph": { + "zh": "创建Zep图谱...", + "en": "Creating the Zep graph...", + }, + "graph.build_setting_ontology": { + "zh": "设置本体定义...", + "en": "Setting the ontology...", + }, + "graph.build_add_batches_start": { + "zh": "开始添加 {total_chunks} 个文本块...", + "en": "Starting to add {total_chunks} text chunks...", + }, + "graph.build_batch_sending": { + "zh": "发送第 {batch_num}/{total_batches} 批数据 ({chunk_count} 块)...", + "en": "Sending batch {batch_num}/{total_batches} ({chunk_count} chunk(s))...", + }, + "graph.build_batch_retry": { + "zh": "批次 {batch_num} 发送失败,{wait_time:.0f}秒后重试 ({attempt}/{total})...", + "en": "Batch {batch_num} failed to send, retrying in {wait_time:.0f}s ({attempt}/{total})...", + }, + "graph.build_waiting_for_zep": { + "zh": "等待Zep处理数据...", + "en": "Waiting for Zep to process the data...", + }, + "graph.build_wait_not_required": { + "zh": "无需等待(没有 episode)", + "en": "No waiting required (no episodes)", + }, + "graph.build_wait_started": { + "zh": "开始等待 {total_episodes} 个文本块处理...", + "en": "Waiting for {total_episodes} text chunk(s) to finish processing...", + }, + "graph.build_wait_partial_timeout": { + "zh": "部分文本块超时,已完成 {completed_count}/{total_episodes}", + "en": "Some text chunks timed out, completed {completed_count}/{total_episodes}", + }, + "graph.build_wait_progress": { + "zh": "Zep处理中... {completed_count}/{total_episodes} 完成, {pending_count} 待处理 ({elapsed}秒)", + "en": "Zep processing... {completed_count}/{total_episodes} complete, {pending_count} pending ({elapsed}s)", + }, + "graph.build_wait_completed": { + "zh": "处理完成: {completed_count}/{total_episodes}", + "en": "Processing completed: {completed_count}/{total_episodes}", + }, + "graph.build_fetching_graph_data": { + "zh": "获取图谱数据...", + "en": "Fetching graph data...", + }, + "graph.build_started_worker": { + "zh": "开始构建图谱...", + "en": "Starting graph build...", + }, + "graph.build_worker_started_log": { + "zh": "[{task_id}] 开始构建图谱...", + "en": "[{task_id}] Starting graph build...", + }, + "graph.build_graph_created": { + "zh": "图谱已创建: {graph_id}", + "en": "Graph created: {graph_id}", + }, + "graph.build_ontology_set": { + "zh": "本体已设置", + "en": "Ontology configured", + }, + "graph.build_chunks_split": { + "zh": "文本已分割为 {total_chunks} 个块", + "en": "Text split into {total_chunks} chunk(s)", + }, + "graph.build_fetching_graph_info": { + "zh": "获取图谱信息...", + "en": "Fetching graph info...", + }, + "graph.build_completed": { + "zh": "图谱构建完成", + "en": "Graph build completed", + }, + "graph.build_worker_completed_log": { + "zh": "[{task_id}] 图谱构建完成: graph_id={graph_id}, 节点={node_count}, 边={edge_count}", + "en": "[{task_id}] Graph build completed: graph_id={graph_id}, nodes={node_count}, edges={edge_count}", + }, + "graph.build_failed": { + "zh": "构建失败: {details}", + "en": "Build failed: {details}", + }, + "graph.build_worker_failed_log": { + "zh": "[{task_id}] 图谱构建失败: {details}", + "en": "[{task_id}] Graph build failed: {details}", + }, + "report.simulation_id_required": { + "zh": "请提供 simulation_id", + "en": "Please provide simulation_id", + }, + "report.task_or_simulation_required": { + "zh": "请提供 task_id 或 simulation_id", + "en": "Please provide task_id or simulation_id", + }, + "report.task_not_found": { + "zh": "任务不存在: {task_id}", + "en": "Task not found: {task_id}", + }, + "report.already_exists": { + "zh": "报告已存在", + "en": "The report already exists", + }, + "report.generation_started": { + "zh": "报告生成任务已启动,请通过 /api/report/generate/status 查询进度", + "en": "Report generation has started. Query /api/report/generate/status for progress.", + }, + "report.already_generated": { + "zh": "报告已生成", + "en": "The report has already been generated", + }, + "report.project_not_found": { + "zh": "项目不存在: {project_id}", + "en": "Project not found: {project_id}", + }, + "report.graph_id_required_built": { + "zh": "缺少图谱ID,请确保已构建图谱", + "en": "Missing graph_id. Make sure the project graph has been built.", + }, + "report.graph_id_required": { + "zh": "缺少图谱ID", + "en": "Missing graph_id", + }, + "report.requirement_missing": { + "zh": "缺少模拟需求描述", + "en": "Missing simulation requirement", + }, + "report.generation_failed": { + "zh": "报告生成失败", + "en": "Report generation failed", + }, + "report.error_generation_failed": { + "zh": "报告生成失败", + "en": "Report generation failed", + }, + "report.error_start_generation_failed": { + "zh": "启动报告生成任务失败", + "en": "Failed to start report generation", + }, + "report.error_task_status_failed": { + "zh": "查询任务状态失败", + "en": "Failed to query task status", + }, + "report.error_get_failed": { + "zh": "获取报告失败", + "en": "Failed to fetch the report", + }, + "report.error_list_failed": { + "zh": "列出报告失败", + "en": "Failed to list reports", + }, + "report.error_download_failed": { + "zh": "下载报告失败", + "en": "Failed to download the report", + }, + "report.error_delete_failed": { + "zh": "删除报告失败", + "en": "Failed to delete the report", + }, + "report.error_chat_failed": { + "zh": "对话失败", + "en": "Report chat failed", + }, + "report.error_progress_failed": { + "zh": "获取报告进度失败", + "en": "Failed to fetch report progress", + }, + "report.error_section_list_failed": { + "zh": "获取章节列表失败", + "en": "Failed to fetch the section list", + }, + "report.error_section_content_failed": { + "zh": "获取章节内容失败", + "en": "Failed to fetch section content", + }, + "report.error_status_failed": { + "zh": "检查报告状态失败", + "en": "Failed to check report status", + }, + "report.error_agent_log_failed": { + "zh": "获取Agent日志失败", + "en": "Failed to fetch the agent log", + }, + "report.error_console_log_failed": { + "zh": "获取控制台日志失败", + "en": "Failed to fetch the console log", + }, + "report.error_graph_search_failed": { + "zh": "图谱搜索失败", + "en": "Graph search failed", + }, + "report.error_graph_stats_failed": { + "zh": "获取图谱统计失败", + "en": "Failed to fetch graph statistics", + }, + "report.log_started": { + "zh": "报告生成任务开始", + "en": "Report generation task started", + }, + "report.log_planning_started": { + "zh": "开始规划报告大纲", + "en": "Starting report outline planning", + }, + "report.log_planning_context_loaded": { + "zh": "获取模拟上下文信息", + "en": "Loaded simulation context", + }, + "report.log_planning_completed": { + "zh": "大纲规划完成", + "en": "Outline planning completed", + }, + "report.log_section_started": { + "zh": "开始生成章节: {section_title}", + "en": "Starting section generation: {section_title}", + }, + "report.log_react_iteration": { + "zh": "ReACT 第{iteration}轮思考", + "en": "ReACT iteration {iteration}", + }, + "report.log_tool_call": { + "zh": "调用工具: {tool_name}", + "en": "Calling tool: {tool_name}", + }, + "report.log_tool_result": { + "zh": "工具 {tool_name} 返回结果", + "en": "Tool {tool_name} returned a result", + }, + "report.log_llm_response": { + "zh": "LLM 响应 (工具调用: {has_tool_calls}, 最终答案: {has_final_answer})", + "en": "LLM response (tool calls: {has_tool_calls}, final answer: {has_final_answer})", + }, + "report.log_section_content_completed": { + "zh": "章节 {section_title} 内容生成完成", + "en": "Section content generated: {section_title}", + }, + "report.log_section_completed": { + "zh": "章节 {section_title} 生成完成", + "en": "Section generation completed: {section_title}", + }, + "report.log_completed": { + "zh": "报告生成完成", + "en": "Report generation completed", + }, + "report.log_error": { + "zh": "发生错误: {error}", + "en": "Error occurred: {error}", + }, + "report.not_found": { + "zh": "报告不存在: {report_id}", + "en": "Report not found: {report_id}", + }, + "report.not_available_for_simulation": { + "zh": "该模拟暂无报告: {simulation_id}", + "en": "No report exists for simulation: {simulation_id}", + }, + "report.deleted": { + "zh": "报告已删除: {report_id}", + "en": "Report deleted: {report_id}", + }, + "report.message_required": { + "zh": "请提供 message", + "en": "Please provide message", + }, + "report.progress_not_available": { + "zh": "报告不存在或进度信息不可用: {report_id}", + "en": "Report not found or progress data is unavailable: {report_id}", + }, + "report.section_not_found": { + "zh": "章节不存在: section_{section_index:02d}.md", + "en": "Section not found: section_{section_index:02d}.md", + }, + "report.graph_id_and_query_required": { + "zh": "请提供 graph_id 和 query", + "en": "Please provide graph_id and query", + }, + "report.graph_id_required_for_tools": { + "zh": "请提供 graph_id", + "en": "Please provide graph_id", + }, + "simulation.timeout_invalid_number": { + "zh": "timeout 必须是大于 0 的数字", + "en": "timeout must be a number greater than 0", + }, + "simulation.timeout_invalid_nonpositive": { + "zh": "timeout 必须大于 0", + "en": "timeout must be greater than 0", + }, + "simulation.ipc_timeout": { + "zh": "等待命令响应超时 ({timeout}秒)", + "en": "Timed out while waiting for the command response ({timeout}s)", + }, + "simulation.ipc_command_sent": { + "zh": "发送IPC命令: {command_type}, command_id={command_id}", + "en": "Sent IPC command: {command_type}, command_id={command_id}", + }, + "simulation.ipc_response_received": { + "zh": "收到IPC响应: command_id={command_id}, status={status}", + "en": "Received IPC response: command_id={command_id}, status={status}", + }, + "simulation.ipc_response_parse_failed": { + "zh": "解析IPC响应失败: {error}", + "en": "Failed to parse the IPC response: {error}", + }, + "simulation.ipc_command_file_read_failed": { + "zh": "读取IPC命令文件失败: {path}, {error}", + "en": "Failed to read the IPC command file: {path}, {error}", + }, + "simulation.simulation_id_required": { + "zh": "请提供 simulation_id", + "en": "Please provide simulation_id", + }, + "simulation.agent_id_required": { + "zh": "请提供 agent_id", + "en": "Please provide agent_id", + }, + "simulation.prompt_required": { + "zh": "请提供 prompt(采访问题)", + "en": "Please provide prompt (interview question)", + }, + "simulation.platform_invalid": { + "zh": "platform 参数只能是 'twitter' 或 'reddit'", + "en": "platform must be 'twitter' or 'reddit'", + }, + "simulation.environment_not_alive": { + "zh": "模拟环境未运行或已关闭。请确保模拟已完成并进入等待命令模式。", + "en": "The simulation environment is not running or has already closed. Make sure the simulation completed and is in wait-for-commands mode.", + }, + "simulation.interview_command_sent": { + "zh": "发送Interview命令: simulation_id={simulation_id}, agent_id={agent_id}, platform={platform}", + "en": "Sent interview command: simulation_id={simulation_id}, agent_id={agent_id}, platform={platform}", + }, + "simulation.batch_interview_command_sent": { + "zh": "发送批量Interview命令: simulation_id={simulation_id}, count={count}, platform={platform}", + "en": "Sent batch interview command: simulation_id={simulation_id}, count={count}, platform={platform}", + }, + "simulation.all_interview_command_sent": { + "zh": "发送全局Interview命令: simulation_id={simulation_id}, agent_count={agent_count}, platform={platform}", + "en": "Sent global interview command: simulation_id={simulation_id}, agent_count={agent_count}, platform={platform}", + }, + "simulation.close_env_command_sent": { + "zh": "发送关闭环境命令: simulation_id={simulation_id}", + "en": "Sent close-environment command: simulation_id={simulation_id}", + }, + "simulation.interview_timeout": { + "zh": "等待Interview响应超时: {details}", + "en": "Timed out while waiting for the interview response: {details}", + }, + "simulation.batch_interview_timeout": { + "zh": "等待批量Interview响应超时: {details}", + "en": "Timed out while waiting for the batch interview response: {details}", + }, + "simulation.all_interview_timeout": { + "zh": "等待全局Interview响应超时: {details}", + "en": "Timed out while waiting for the global interview response: {details}", + }, + "simulation.max_rounds_positive": { + "zh": "max_rounds 必须是正整数", + "en": "max_rounds must be a positive integer", + }, + "simulation.max_rounds_integer": { + "zh": "max_rounds 必须是有效的整数", + "en": "max_rounds must be a valid integer", + }, + "simulation.invalid_platform_type": { + "zh": "无效的平台类型: {platform},可选: twitter/reddit/parallel", + "en": "Invalid platform type: {platform}. Expected twitter/reddit/parallel", + }, + "simulation.not_found": { + "zh": "模拟不存在: {simulation_id}", + "en": "Simulation not found: {simulation_id}", + }, + "simulation.delete_active": { + "zh": "模拟仍在运行中,无法删除: {simulation_id}", + "en": "Cannot delete simulation while it is still active: {simulation_id}", + }, + "simulation.deleted": { + "zh": "模拟记录已删除: {simulation_id}", + "en": "Deleted simulation record: {simulation_id}", + }, + "simulation.project_graph_required": { + "zh": "项目尚未构建图谱,请先调用 /api/graph/build", + "en": "The project graph has not been built yet. Call /api/graph/build first.", + }, + "simulation.project_requirement_required": { + "zh": "项目缺少模拟需求描述 (simulation_requirement)", + "en": "The project is missing simulation_requirement", + }, + "simulation.prepare_dir_missing": { + "zh": "模拟目录不存在", + "en": "The simulation directory does not exist", + }, + "simulation.prepare_missing_files": { + "zh": "缺少必要文件", + "en": "Missing required files", + }, + "simulation.prepare_status_not_ready": { + "zh": "状态不在已准备列表中或config_generated为false: status={status}, config_generated={config_generated}", + "en": "The simulation is not in a prepared state or config_generated is false: status={status}, config_generated={config_generated}", + }, + "simulation.prepare_state_read_failed": { + "zh": "读取状态文件失败: {details}", + "en": "Failed to read the simulation state file: {details}", + }, + "simulation.prepare_already_done": { + "zh": "已有完成的准备工作,无需重复生成", + "en": "Preparation already exists and does not need to run again", + }, + "simulation.prepare_started": { + "zh": "准备任务已启动,请通过 /api/simulation/prepare/status 查询进度", + "en": "Preparation has started. Query /api/simulation/prepare/status for progress.", + }, + "simulation.prepare_exists_short": { + "zh": "已有完成的准备工作", + "en": "Preparation already exists", + }, + "simulation.prepare_not_started": { + "zh": "尚未开始准备,请调用 /api/simulation/prepare 开始", + "en": "Preparation has not started yet. Call /api/simulation/prepare first.", + }, + "simulation.prepare_task_completed_existing": { + "zh": "任务已完成(准备工作已存在)", + "en": "The task is already complete because preparation already exists", + }, + "simulation.prepare_stage_reading": { + "zh": "读取图谱实体", + "en": "Reading graph entities", + }, + "simulation.prepare_stage_generating_profiles": { + "zh": "生成Agent人设", + "en": "Generating agent profiles", + }, + "simulation.prepare_stage_generating_config": { + "zh": "生成模拟配置", + "en": "Generating simulation config", + }, + "simulation.prepare_stage_copying_scripts": { + "zh": "准备模拟脚本", + "en": "Preparing simulation scripts", + }, + "simulation.prepare_initializing": { + "zh": "开始准备模拟环境...", + "en": "Preparing the simulation environment...", + }, + "simulation.prepare_connecting_graph": { + "zh": "正在连接Zep图谱...", + "en": "Connecting to the Zep graph...", + }, + "simulation.prepare_reading_nodes": { + "zh": "正在读取节点数据...", + "en": "Reading node data...", + }, + "simulation.prepare_entities_completed": { + "zh": "完成,共 {count} 个实体", + "en": "Completed with {count} entities", + }, + "simulation.prepare_generation_starting": { + "zh": "开始生成...", + "en": "Starting generation...", + }, + "simulation.prepare_saving_profiles": { + "zh": "保存Profile文件...", + "en": "Saving profile files...", + }, + "simulation.prepare_profiles_completed": { + "zh": "完成,共 {count} 个Profile", + "en": "Completed with {count} profiles", + }, + "simulation.prepare_analyzing_requirement": { + "zh": "正在分析模拟需求...", + "en": "Analyzing the simulation requirement...", + }, + "simulation.prepare_generating_config": { + "zh": "正在调用LLM生成配置...", + "en": "Calling the LLM to generate the config...", + }, + "simulation.prepare_saving_config": { + "zh": "正在保存配置文件...", + "en": "Saving the config file...", + }, + "simulation.prepare_config_completed": { + "zh": "配置生成完成", + "en": "Configuration generation completed", + }, + "simulation.task_status_query_failed": { + "zh": "查询任务状态失败: {error}", + "en": "Failed to query task status: {error}", + }, + "simulation.report_lookup_failed": { + "zh": "查找 simulation {simulation_id} 的 report 失败: {error}", + "en": "Failed to find the report for simulation {simulation_id}: {error}", + }, + "simulation.realtime_profiles_read_failed": { + "zh": "读取 profiles 文件失败(可能正在写入中): {error}", + "en": "Failed to read the profiles file (it may still be being written): {error}", + }, + "simulation.realtime_config_read_failed": { + "zh": "读取 config 文件失败(可能正在写入中): {error}", + "en": "Failed to read the config file (it may still be being written): {error}", + }, + "simulation.config_output_truncated": { + "zh": "LLM输出被截断, 尝试修复...", + "en": "LLM output was truncated; attempting to repair JSON...", + }, + "simulation.run_instructions_activate_env": { + "zh": "1. 激活conda环境: conda activate MiroFish", + "en": "1. Activate the conda environment: conda activate MiroFish", + }, + "simulation.run_instructions_run_header": { + "zh": "2. 运行模拟 (脚本位于 {scripts_dir}):", + "en": "2. Run the simulation (scripts are located in {scripts_dir}):", + }, + "simulation.run_instructions_twitter": { + "zh": " - 单独运行Twitter: python {scripts_dir}/run_twitter_simulation.py --config {config_path}", + "en": " - Run Twitter only: python {scripts_dir}/run_twitter_simulation.py --config {config_path}", + }, + "simulation.run_instructions_reddit": { + "zh": " - 单独运行Reddit: python {scripts_dir}/run_reddit_simulation.py --config {config_path}", + "en": " - Run Reddit only: python {scripts_dir}/run_reddit_simulation.py --config {config_path}", + }, + "simulation.run_instructions_parallel": { + "zh": " - 并行运行双平台: python {scripts_dir}/run_parallel_simulation.py --config {config_path}", + "en": " - Run both platforms in parallel: python {scripts_dir}/run_parallel_simulation.py --config {config_path}", + }, + "simulation.graph_id_required": { + "zh": "请提供 graph_id", + "en": "Please provide graph_id", + }, + "simulation.no_matching_entities": { + "zh": "没有找到符合条件的实体", + "en": "No matching entities were found", + }, + "simulation.no_matching_entities_build_graph": { + "zh": "没有找到符合条件的实体,请检查图谱是否正确构建", + "en": "No matching entities were found. Check that the graph was built correctly.", + }, + "simulation.interviews_required": { + "zh": "请提供 interviews(采访列表)", + "en": "Please provide interviews", + }, + "simulation.interview_item_agent_required": { + "zh": "采访列表第{index}项缺少 agent_id", + "en": "Interview item {index} is missing agent_id", + }, + "simulation.interview_item_prompt_required": { + "zh": "采访列表第{index}项缺少 prompt", + "en": "Interview item {index} is missing prompt", + }, + "simulation.interview_item_platform_invalid": { + "zh": "采访列表第{index}项的platform只能是 'twitter' 或 'reddit'", + "en": "Interview item {index} platform must be 'twitter' or 'reddit'", + }, + "simulation.env_running": { + "zh": "环境正在运行,可以接收Interview命令", + "en": "The environment is running and can accept interview commands", + }, + "simulation.env_closed": { + "zh": "环境未运行或已关闭", + "en": "The environment is not running or has already closed", + }, + "simulation.env_already_closed": { + "zh": "环境已经关闭", + "en": "The environment is already closed", + }, + "simulation.env_close_sent": { + "zh": "环境关闭命令已发送", + "en": "The environment close command was sent", + }, + "simulation.env_close_timeout": { + "zh": "环境关闭命令已发送(等待响应超时,环境可能正在关闭)", + "en": "The environment close command was sent, but waiting for the response timed out and the environment may already be shutting down", + }, + "simulation.running_force_required": { + "zh": "模拟正在运行中,请先调用 /stop 接口停止,或使用 force=true 强制重新开始", + "en": "The simulation is already running. Stop it via /stop first, or use force=true to restart it.", + }, + "simulation.not_running_status": { + "zh": "模拟未在运行: {simulation_id}, status={status}", + "en": "The simulation is not running: {simulation_id}, status={status}", + }, + "simulation.not_ready": { + "zh": "模拟未准备好,当前状态: {status},请先调用 /prepare 接口", + "en": "The simulation is not ready yet. Current status: {status}. Call /prepare first.", + }, + "simulation.graph_memory_requires_graph": { + "zh": "启用图谱记忆更新需要有效的 graph_id,请确保项目已构建图谱", + "en": "Enabling graph-memory updates requires a valid graph_id. Make sure the project graph has been built.", + }, + "simulation.config_missing_prepare": { + "zh": "模拟配置不存在,请先调用 /prepare 接口", + "en": "The simulation config does not exist yet. Call /prepare first.", + }, + "simulation.config_no_agents": { + "zh": "模拟配置中没有Agent: {simulation_id}", + "en": "The simulation config has no agents: {simulation_id}", + }, + "simulation.config_file_missing_prepare": { + "zh": "配置文件不存在,请先调用 /prepare 接口", + "en": "The config file does not exist yet. Call /prepare first.", + }, + "simulation.script_unknown": { + "zh": "未知脚本: {script_name},可选: {allowed}", + "en": "Unknown script: {script_name}. Allowed values: {allowed}", + }, + "simulation.script_missing": { + "zh": "脚本文件不存在: {script_name}", + "en": "Script file not found: {script_name}", + }, + "simulation.runner_dependency_error": { + "zh": "当前后端未安装可选的 OASIS 仿真运行时依赖。请先执行 `npm run setup:backend:simulation`,或在 backend 目录执行 `uv sync --extra simulation`。", + "en": "The optional OASIS simulation runtime dependencies are not installed. Run `npm run setup:backend:simulation`, or `uv sync --extra simulation` inside the backend directory first.", + }, + "simulation.run_state_load_failed": { + "zh": "加载运行状态失败: {error}", + "en": "Failed to load the run state: {error}", + }, + "simulation.interview_history_read_failed": { + "zh": "读取Interview历史失败 ({platform_name}): {error}", + "en": "Failed to read interview history ({platform_name}): {error}", + }, + "simulation.already_running": { + "zh": "模拟已在运行中: {simulation_id}", + "en": "Simulation is already running: {simulation_id}", + }, + "simulation.start_config_missing": { + "zh": "模拟配置不存在,请先调用 /prepare 接口", + "en": "The simulation config does not exist yet. Call /prepare first.", + }, + "simulation.rounds_truncated": { + "zh": "轮数已截断: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})", + "en": "Rounds truncated: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})", + }, + "simulation.graph_id_required_for_memory": { + "zh": "启用图谱记忆更新时必须提供 graph_id", + "en": "graph_id is required when graph-memory updates are enabled", + }, + "simulation.graph_memory_enabled": { + "zh": "已启用图谱记忆更新: simulation_id={simulation_id}, graph_id={graph_id}", + "en": "Graph-memory updates enabled: simulation_id={simulation_id}, graph_id={graph_id}", + }, + "simulation.graph_memory_enable_failed": { + "zh": "创建图谱记忆更新器失败: {details}", + "en": "Failed to create the graph-memory updater: {details}", + }, + "simulation.script_path_missing": { + "zh": "脚本不存在: {script_path}", + "en": "Script not found: {script_path}", + }, + "simulation.started": { + "zh": "模拟启动成功: {simulation_id}, pid={pid}, platform={platform}", + "en": "Simulation started: {simulation_id}, pid={pid}, platform={platform}", + }, + "simulation.completed": { + "zh": "模拟完成: {simulation_id}", + "en": "Simulation completed: {simulation_id}", + }, + "simulation.failed": { + "zh": "模拟失败: {simulation_id}, error={error}", + "en": "Simulation failed: {simulation_id}, error={error}", + }, + "simulation.monitor_thread_failed": { + "zh": "监控线程异常: {simulation_id}, error={error}", + "en": "Simulation monitor thread failed: {simulation_id}, error={error}", + }, + "simulation.graph_memory_stopped": { + "zh": "已停止图谱记忆更新: simulation_id={simulation_id}", + "en": "Stopped graph-memory updates: simulation_id={simulation_id}", + }, + "simulation.graph_memory_stop_failed": { + "zh": "停止图谱记忆更新器失败: {details}", + "en": "Failed to stop the graph-memory updater: {details}", + }, + "simulation.platform_completed": { + "zh": "{platform} 模拟已完成: {simulation_id}, total_rounds={total_rounds}, total_actions={total_actions}", + "en": "{platform} simulation completed: {simulation_id}, total_rounds={total_rounds}, total_actions={total_actions}", + }, + "simulation.all_platforms_completed": { + "zh": "所有平台模拟已完成: {simulation_id}", + "en": "All platform simulations completed: {simulation_id}", + }, + "simulation.read_action_log_failed": { + "zh": "读取动作日志失败: {log_path}, error={error}", + "en": "Failed to read the action log: {log_path}, error={error}", + }, + "simulation.terminate_process_tree_windows": { + "zh": "终止进程树 (Windows): simulation={simulation_id}, pid={pid}", + "en": "Terminating process tree (Windows): simulation={simulation_id}, pid={pid}", + }, + "simulation.process_force_kill": { + "zh": "进程未响应,强制终止: {simulation_id}", + "en": "Process did not respond and will be force-killed: {simulation_id}", + }, + "simulation.taskkill_failed_fallback": { + "zh": "taskkill 失败,尝试 terminate: {details}", + "en": "taskkill failed, falling back to terminate: {details}", + }, + "simulation.terminate_process_group_unix": { + "zh": "终止进程组 (Unix): simulation={simulation_id}, pgid={pgid}", + "en": "Terminating process group (Unix): simulation={simulation_id}, pgid={pgid}", + }, + "simulation.process_group_force_kill": { + "zh": "进程组未响应 SIGTERM,强制终止: {simulation_id}", + "en": "Process group did not respond to SIGTERM and will be force-killed: {simulation_id}", + }, + "simulation.terminate_failed": { + "zh": "终止进程组失败: {simulation_id}, error={error}", + "en": "Failed to terminate the process group: {simulation_id}, error={error}", + }, + "simulation.stopped": { + "zh": "模拟已停止: {simulation_id}", + "en": "Simulation stopped: {simulation_id}", + }, + "simulation.process_exit": { + "zh": "进程退出码: {exit_code}, 错误: {details}", + "en": "Process exited with code {exit_code}. Error: {details}", + }, + "simulation.process_exit_huggingface_network": { + "zh": "模拟运行失败:下载 HuggingFace 模型或资源时出现网络错误。请检查当前机器是否能访问 huggingface.co,并确认代理/VPN 配置后重试。", + "en": "The simulation run failed while downloading HuggingFace models or assets. Check that this machine can reach huggingface.co, then verify your proxy/VPN settings and retry.", + }, + "simulation.cleanup_dir_missing": { + "zh": "模拟目录不存在,无需清理", + "en": "The simulation directory does not exist and does not need cleanup", + }, + "simulation.cleanup_delete_failed": { + "zh": "删除 {target} 失败: {details}", + "en": "Failed to delete {target}: {details}", + }, + "simulation.cleanup_completed": { + "zh": "清理模拟日志完成: {simulation_id}, 删除文件: {cleaned_files}", + "en": "Simulation log cleanup completed: {simulation_id}, deleted files: {cleaned_files}", + }, + "simulation.cleanup_all_started": { + "zh": "正在清理所有模拟进程...", + "en": "Cleaning up all simulation processes...", + }, + "simulation.graph_memory_stop_all_failed": { + "zh": "停止图谱记忆更新器失败: {details}", + "en": "Failed to stop graph-memory updaters: {details}", + }, + "simulation.terminating_process": { + "zh": "终止模拟进程: {simulation_id}, pid={pid}", + "en": "Terminating simulation process: {simulation_id}, pid={pid}", + }, + "simulation.state_json_update_attempt": { + "zh": "尝试更新 state.json: {state_file}", + "en": "Attempting to update state.json: {state_file}", + }, + "simulation.state_json_updated": { + "zh": "已更新 state.json 状态为 stopped: {simulation_id}", + "en": "Updated state.json status to stopped: {simulation_id}", + }, + "simulation.state_json_missing": { + "zh": "state.json 不存在: {state_file}", + "en": "state.json does not exist: {state_file}", + }, + "simulation.state_json_update_failed": { + "zh": "更新 state.json 失败: {simulation_id}, error={error}", + "en": "Failed to update state.json: {simulation_id}, error={error}", + }, + "simulation.cleanup_process_failed": { + "zh": "清理进程失败: {simulation_id}, error={error}", + "en": "Failed to clean up the process: {simulation_id}, error={error}", + }, + "simulation.cleanup_all_completed": { + "zh": "模拟进程清理完成", + "en": "Simulation process cleanup completed", + }, + "simulation.stopped_server_shutdown": { + "zh": "服务器关闭,模拟被终止", + "en": "The server is shutting down, so the simulation was stopped", + }, + "simulation.cleanup_signal_received": { + "zh": "收到信号 {signum},开始清理...", + "en": "Received signal {signum}; starting cleanup...", + }, + "simulation.signal_handler_register_failed": { + "zh": "无法注册信号处理器(不在主线程),仅使用 atexit", + "en": "Could not register signal handlers outside the main thread; using atexit only", + }, + "simulation.posts_db_missing": { + "zh": "数据库不存在,模拟可能尚未运行", + "en": "The simulation database does not exist yet. The simulation may not have run for this platform.", + }, + "simulation.prepare_check_status": { + "zh": "检测模拟准备状态: {simulation_id}, status={status}, config_generated={config_generated}", + "en": "Checking simulation prepare state: {simulation_id}, status={status}, config_generated={config_generated}", + }, + "simulation.prepare_auto_ready": { + "zh": "自动更新模拟状态: {simulation_id} preparing -> ready", + "en": "Auto-updated simulation state: {simulation_id} preparing -> ready", + }, + "simulation.prepare_auto_ready_failed": { + "zh": "自动更新状态失败: {error}", + "en": "Failed to auto-update the simulation state: {error}", + }, + "simulation.prepare_check_ready": { + "zh": "模拟 {simulation_id} 检测结果: 已准备完成 (status={status}, config_generated={config_generated})", + "en": "Simulation {simulation_id} prepare check result: ready (status={status}, config_generated={config_generated})", + }, + "simulation.prepare_check_not_ready": { + "zh": "模拟 {simulation_id} 检测结果: 未准备完成 (status={status}, config_generated={config_generated})", + "en": "Simulation {simulation_id} prepare check result: not ready (status={status}, config_generated={config_generated})", + }, + "simulation.force_stop_running": { + "zh": "强制模式:停止运行中的模拟 {simulation_id}", + "en": "Force mode: stopping the running simulation {simulation_id}", + }, + "simulation.force_stop_warning": { + "zh": "停止模拟时出现警告: {error}", + "en": "Stopping the simulation raised a warning: {error}", + }, + "simulation.force_cleanup_logs": { + "zh": "强制模式:清理模拟日志 {simulation_id}", + "en": "Force mode: cleaning simulation logs for {simulation_id}", + }, + "simulation.force_cleanup_warning": { + "zh": "清理日志时出现警告: {errors}", + "en": "Cleaning simulation logs raised a warning: {errors}", + }, + "simulation.reset_ready_after_prepare": { + "zh": "模拟 {simulation_id} 准备工作已完成,重置状态为 ready(原状态: {status})", + "en": "Simulation {simulation_id} already has prepared assets; resetting state to ready (previous status: {status})", + }, + "simulation.graph_memory_enable_request": { + "zh": "启用图谱记忆更新: simulation_id={simulation_id}, graph_id={graph_id}", + "en": "Enabling graph-memory updates: simulation_id={simulation_id}, graph_id={graph_id}", + }, + "simulation.created_log": { + "zh": "创建模拟: {simulation_id}, project={project_id}, graph={graph_id}", + "en": "Created simulation: {simulation_id}, project={project_id}, graph={graph_id}", + }, + "simulation.prepare_completed_log": { + "zh": "模拟准备完成: {simulation_id}, entities={entities}, profiles={profiles}", + "en": "Simulation preparation completed: {simulation_id}, entities={entities}, profiles={profiles}", + }, + "simulation.prepare_failed_log": { + "zh": "模拟准备失败: {simulation_id}, error={error}", + "en": "Simulation preparation failed: {simulation_id}, error={error}", + }, +} + + +def get_locale(preferred: str | None = None) -> str: + """Resolve the current locale from an explicit value or the request header.""" + if preferred in {"zh", "en"}: + return preferred + if has_request_context(): + header = (request.headers.get("X-Locale") or "").strip().lower() + if header.startswith("en"): + return "en" + return DEFAULT_LOCALE + + +def tr(key: str, locale: str | None = None, **params) -> str: + """Translate a known backend message key.""" + resolved_locale = get_locale(locale) + template = TRANSLATIONS.get(key, {}).get(resolved_locale) + if template is None: + template = TRANSLATIONS.get(key, {}).get(DEFAULT_LOCALE, key) + return template.format(**params) diff --git a/backend/app/models/task.py b/backend/app/models/task.py index e15f35fb..48cf76f4 100644 --- a/backend/app/models/task.py +++ b/backend/app/models/task.py @@ -3,6 +3,9 @@ 用于跟踪长时间运行的任务(如图谱构建) """ +import json +import os +import tempfile import uuid import threading from datetime import datetime @@ -10,6 +13,9 @@ from typing import Dict, Any, Optional from dataclasses import dataclass, field +from ..config import Config +from ..i18n import tr + class TaskStatus(str, Enum): """任务状态枚举""" @@ -50,6 +56,23 @@ def to_dict(self) -> Dict[str, Any]: "metadata": self.metadata, } + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Task": + """从持久化字典恢复任务。""" + return cls( + task_id=data["task_id"], + task_type=data["task_type"], + status=TaskStatus(data["status"]), + created_at=datetime.fromisoformat(data["created_at"]), + updated_at=datetime.fromisoformat(data["updated_at"]), + progress=data.get("progress", 0), + message=data.get("message", ""), + progress_detail=data.get("progress_detail") or {}, + result=data.get("result"), + error=data.get("error"), + metadata=data.get("metadata") or {}, + ) + class TaskManager: """ @@ -68,7 +91,60 @@ def __new__(cls): cls._instance = super().__new__(cls) cls._instance._tasks: Dict[str, Task] = {} cls._instance._task_lock = threading.Lock() + cls._instance._load_tasks() return cls._instance + + @staticmethod + def _get_state_path() -> str: + return os.path.join(Config.UPLOAD_FOLDER, "tasks", "task_state.json") + + def _load_tasks(self) -> None: + state_path = self._get_state_path() + if not os.path.exists(state_path): + return + + try: + with open(state_path, "r", encoding="utf-8") as fh: + payload = json.load(fh) + except (OSError, json.JSONDecodeError, ValueError): + self._tasks = {} + return + + tasks = payload.get("tasks", []) + if not isinstance(tasks, list): + self._tasks = {} + return + + loaded_tasks: Dict[str, Task] = {} + for item in tasks: + if not isinstance(item, dict): + continue + try: + task = Task.from_dict(item) + except (KeyError, TypeError, ValueError): + continue + loaded_tasks[task.task_id] = task + self._tasks = loaded_tasks + + def _persist_tasks(self) -> None: + state_path = self._get_state_path() + state_dir = os.path.dirname(state_path) + os.makedirs(state_dir, exist_ok=True) + + payload = { + "tasks": [ + task.to_dict() + for task in sorted(self._tasks.values(), key=lambda item: item.created_at, reverse=True) + ] + } + fd, temp_path = tempfile.mkstemp(prefix="task-state-", suffix=".json", dir=state_dir) + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + json.dump(payload, fh, ensure_ascii=False, indent=2) + os.replace(temp_path, state_path) + finally: + if os.path.exists(temp_path): + os.unlink(temp_path) def create_task(self, task_type: str, metadata: Optional[Dict] = None) -> str: """ @@ -95,7 +171,8 @@ def create_task(self, task_type: str, metadata: Optional[Dict] = None) -> str: with self._task_lock: self._tasks[task_id] = task - + self._persist_tasks() + return task_id def get_task(self, task_id: str) -> Optional[Task]: @@ -141,23 +218,24 @@ def update_task( task.error = error if progress_detail is not None: task.progress_detail = progress_detail + self._persist_tasks() - def complete_task(self, task_id: str, result: Dict): + def complete_task(self, task_id: str, result: Dict, locale: str | None = None): """标记任务完成""" self.update_task( task_id, status=TaskStatus.COMPLETED, progress=100, - message="任务完成", + message=tr("task.completed", locale), result=result ) - def fail_task(self, task_id: str, error: str): + def fail_task(self, task_id: str, error: str, locale: str | None = None): """标记任务失败""" self.update_task( task_id, status=TaskStatus.FAILED, - message="任务失败", + message=tr("task.failed", locale), error=error ) @@ -181,4 +259,5 @@ def cleanup_old_tasks(self, max_age_hours: int = 24): ] for tid in old_ids: del self._tasks[tid] - + if old_ids: + self._persist_tasks() diff --git a/backend/app/services/graph_builder.py b/backend/app/services/graph_builder.py index 0e0444bf..438c2101 100644 --- a/backend/app/services/graph_builder.py +++ b/backend/app/services/graph_builder.py @@ -7,16 +7,31 @@ import uuid import time import threading +import re +from copy import deepcopy from typing import Dict, Any, List, Optional, Callable from dataclasses import dataclass +from email.utils import parsedate_to_datetime from zep_cloud.client import Zep from zep_cloud import EpisodeData, EntityEdgeSourceTarget from ..config import Config +from ..i18n import get_locale, tr from ..models.task import TaskManager, TaskStatus +from ..utils.logger import get_logger from ..utils.zep_paging import fetch_all_nodes, fetch_all_edges from .text_processor import TextProcessor +from .zep_entity_reader import EntityNode, ZepEntityReader + + +def _fetch_with_optional_locale(fetcher: Callable[..., Any], client: Zep, graph_id: str, locale: str) -> Any: + try: + return fetcher(client, graph_id, locale=locale) + except TypeError as exc: + if "unexpected keyword argument 'locale'" not in str(exc): + raise + return fetcher(client, graph_id) @dataclass @@ -42,13 +57,344 @@ class GraphBuilderService: 负责调用Zep API构建知识图谱 """ - def __init__(self, api_key: Optional[str] = None): + def __init__(self, api_key: Optional[str] = None, locale: Optional[str] = None): self.api_key = api_key or Config.ZEP_API_KEY + self.locale = locale or get_locale() if not self.api_key: - raise ValueError("ZEP_API_KEY 未配置") + raise ValueError(tr("config.key_missing", self.locale, name="ZEP_API_KEY")) self.client = Zep(api_key=self.api_key) self.task_manager = TaskManager() + self.logger = get_logger('mirofish.graph_builder') + + def _task_message(self, key: str, **kwargs: Any) -> str: + """Build a localized task-progress message for persisted worker state.""" + return tr(key, self.locale, **kwargs) + + @staticmethod + def _is_retryable_zep_error(error: Exception) -> bool: + """Return whether a Zep operation failure looks transient and safe to retry.""" + status_code = getattr(error, "status_code", None) + if status_code in {408, 409, 423, 425, 429, 500, 502, 503, 504}: + return True + + error_text = str(error).lower() + retry_signals = ( + "429", + "too many requests", + "rate limit", + "timeout", + "timed out", + "temporarily unavailable", + "service unavailable", + "bad gateway", + "gateway timeout", + "connection reset", + "connection aborted", + "connection error", + "remote disconnected", + ) + return any(signal in error_text for signal in retry_signals) + + def _retry_zep_operation( + self, + operation_name: str, + func: Callable[[], Any], + *, + max_retries: Optional[int] = None, + progress_callback: Optional[Callable[[str, float], None]] = None, + progress_message: Optional[Callable[[int, int, float], str]] = None, + progress_value: float = 0, + ) -> Any: + """Retry transient Zep failures with backoff while surfacing progress updates.""" + max_attempts = max_retries or Config.ZEP_RETRY_MAX_ATTEMPTS + base_delay = Config.ZEP_RETRY_BASE_DELAY_SECONDS + + for attempt in range(max_attempts): + try: + return func() + except Exception as error: + should_retry = attempt < max_attempts - 1 and self._is_retryable_zep_error(error) + if not should_retry: + raise + + wait_time = self._get_retry_wait_time(error, attempt, base_delay) + self.logger.warning( + "%s failed on attempt %s/%s, retrying in %.1fs: %s", + operation_name, + attempt + 1, + max_attempts, + wait_time, + error, + ) + if progress_callback and progress_message: + progress_callback(progress_message(attempt + 1, max_attempts, wait_time), progress_value) + time.sleep(wait_time) + + @staticmethod + def _extract_retry_after_seconds(error: Exception) -> Optional[float]: + """Extract Retry-After delay hints from common SDK error shapes.""" + candidates: List[Any] = [] + headers = getattr(error, "headers", None) + if headers: + candidates.append(headers) + + response = getattr(error, "response", None) + response_headers = getattr(response, "headers", None) + if response_headers: + candidates.append(response_headers) + + for header_map in candidates: + for key in ("retry-after", "Retry-After", "retry_after"): + raw_value = header_map.get(key) if hasattr(header_map, "get") else None + if raw_value not in (None, ""): + parsed = GraphBuilderService._parse_retry_after_value(raw_value) + if parsed is not None: + return parsed + + error_text = str(error) + patterns = ( + r"retry-after['\"]?\s*[:=]\s*['\"]?([0-9]+(?:\.[0-9]+)?)", + r"retry after\s+([0-9]+(?:\.[0-9]+)?)\s*(?:seconds?|secs?|s)\b", + ) + for pattern in patterns: + match = re.search(pattern, error_text, re.IGNORECASE) + if match: + return float(match.group(1)) + + return None + + @staticmethod + def _parse_retry_after_value(value: Any) -> Optional[float]: + raw_value = str(value).strip() + if not raw_value: + return None + + try: + return max(0.0, float(raw_value)) + except ValueError: + pass + + try: + retry_at = parsedate_to_datetime(raw_value) + except (TypeError, ValueError, IndexError, OverflowError): + return None + + if retry_at.tzinfo is None: + retry_at = retry_at.astimezone() + return max(0.0, retry_at.timestamp() - time.time()) + + def _get_retry_wait_time(self, error: Exception, attempt: int, base_delay: float) -> float: + """Respect bounded Retry-After hints before falling back to exponential backoff.""" + retry_after = self._extract_retry_after_seconds(error) + max_delay = max(base_delay, Config.ZEP_RETRY_MAX_DELAY_SECONDS) + if retry_after is not None: + return min(max(base_delay, retry_after), max_delay) + return min(base_delay * (2 ** attempt), max_delay) + + def format_user_facing_error(self, error: Exception) -> str: + """Collapse noisy provider exceptions into actionable graph-build messages.""" + status_code = getattr(error, "status_code", None) + error_text = self._normalize_error_text(error) + lowered = error_text.lower() + + if status_code == 401 or ("401" in lowered and "unauthorized" in lowered): + return tr("graph.zep_auth_failed", self.locale) + + if status_code == 403 or "forbidden" in lowered: + return tr("graph.zep_permission_denied", self.locale) + + if "invalid api key" in lowered or "authentication" in lowered: + return tr("graph.zep_auth_failed", self.locale) + + return error_text or error.__class__.__name__ + + @staticmethod + def _normalize_error_text(error: Exception) -> str: + """Strip traceback noise from SDK/provider exceptions before surfacing them.""" + error_text = str(error).strip() + if "Traceback (most recent call last):" not in error_text: + return error_text + + cleaned_lines: List[str] = [] + for raw_line in error_text.splitlines(): + line = raw_line.strip() + if not line: + continue + if ( + line.startswith("Traceback (most recent call last):") + or line.startswith('File "') + or line.startswith("^") + or line.startswith("During handling of the above exception") + ): + continue + cleaned_lines.append(line) + + # Prefer the last meaningful line once stack frames are removed. + if cleaned_lines: + return cleaned_lines[-1] + + return error_text + + @staticmethod + def _entity_type_from_node(node: Dict[str, Any]) -> str: + for label in node.get("labels", []): + if label not in ["Entity", "Node"]: + return label + return "" + + @classmethod + def _node_as_entity(cls, node: Dict[str, Any]) -> EntityNode: + return EntityNode( + uuid=str(node.get("uuid", "")), + name=str(node.get("name", "") or ""), + labels=list(node.get("labels", []) or []), + summary=str(node.get("summary", "") or ""), + attributes=dict(node.get("attributes", {}) or {}), + ) + + @staticmethod + def _unique_values(values: List[Any]) -> List[Any]: + seen = set() + result = [] + for value in values: + if value in (None, ""): + continue + if value in seen: + continue + seen.add(value) + result.append(value) + return result + + @classmethod + def _pick_primary_graph_node(cls, left: Dict[str, Any], right: Dict[str, Any]) -> Dict[str, Any]: + left_name = ZepEntityReader._normalize_entity_name(left.get("name", "")) + right_name = ZepEntityReader._normalize_entity_name(right.get("name", "")) + + if left_name and right_name and len(left_name) != len(right_name): + return left if len(left_name) < len(right_name) else right + + def score(node: Dict[str, Any]) -> int: + return ( + len(node.get("attributes") or {}) * 10 + + len(node.get("summary") or "") + + len(node.get("labels") or []) + ) + + return left if score(left) >= score(right) else right + + @classmethod + def _merge_duplicate_graph_nodes( + cls, nodes: List[Dict[str, Any]] + ) -> tuple[List[Dict[str, Any]], Dict[str, str]]: + merged_nodes: List[Dict[str, Any]] = [] + + for node in nodes: + prepared = { + **deepcopy(node), + "labels": list(node.get("labels", []) or []), + "attributes": dict(node.get("attributes", {}) or {}), + "alias_names": cls._unique_values([*(node.get("alias_names", []) or []), node.get("name")]), + "merged_node_uuids": cls._unique_values( + [*(node.get("merged_node_uuids", []) or []), node.get("uuid")] + ), + } + + duplicate_index = next( + ( + idx + for idx, existing in enumerate(merged_nodes) + if ZepEntityReader._are_duplicate_entities( + cls._node_as_entity(existing), + cls._node_as_entity(prepared), + ) + ), + None, + ) + if duplicate_index is None: + merged_nodes.append(prepared) + continue + + existing = merged_nodes[duplicate_index] + primary = cls._pick_primary_graph_node(existing, prepared) + secondary = prepared if primary is existing else existing + + merged_nodes[duplicate_index] = { + **secondary, + **primary, + "labels": cls._unique_values([*(primary.get("labels", []) or []), *(secondary.get("labels", []) or [])]), + "attributes": { + **(secondary.get("attributes") or {}), + **(primary.get("attributes") or {}), + }, + "summary": max( + [part for part in (primary.get("summary"), secondary.get("summary")) if part], + key=len, + default="", + ), + "created_at": primary.get("created_at") or secondary.get("created_at"), + "alias_names": cls._unique_values( + [ + *(primary.get("alias_names", []) or []), + *(secondary.get("alias_names", []) or []), + primary.get("name"), + secondary.get("name"), + ] + ), + "merged_node_uuids": cls._unique_values( + [ + *(primary.get("merged_node_uuids", []) or []), + *(secondary.get("merged_node_uuids", []) or []), + ] + ), + } + + uuid_remap: Dict[str, str] = {} + sanitized_nodes: List[Dict[str, Any]] = [] + for node in merged_nodes: + merged_uuids = cls._unique_values(node.get("merged_node_uuids", []) or []) + for raw_uuid in merged_uuids: + uuid_remap[raw_uuid] = node["uuid"] + + sanitized = dict(node) + if len(node.get("alias_names", []) or []) <= 1: + sanitized.pop("alias_names", None) + if len(merged_uuids) <= 1: + sanitized.pop("merged_node_uuids", None) + sanitized_nodes.append(sanitized) + + return sanitized_nodes, uuid_remap + + @classmethod + def _deduplicate_graph_edges( + cls, + edges: List[Dict[str, Any]], + uuid_remap: Dict[str, str], + node_name_map: Dict[str, str], + ) -> List[Dict[str, Any]]: + deduplicated: List[Dict[str, Any]] = [] + seen_keys = set() + + for edge in edges: + remapped = dict(edge) + remapped["source_node_uuid"] = uuid_remap.get(edge.get("source_node_uuid"), edge.get("source_node_uuid")) + remapped["target_node_uuid"] = uuid_remap.get(edge.get("target_node_uuid"), edge.get("target_node_uuid")) + remapped["source_node_name"] = node_name_map.get(remapped["source_node_uuid"], edge.get("source_node_name", "")) + remapped["target_node_name"] = node_name_map.get(remapped["target_node_uuid"], edge.get("target_node_name", "")) + + edge_key = ( + remapped.get("name", ""), + remapped.get("fact", ""), + remapped.get("fact_type", ""), + remapped.get("source_node_uuid", ""), + remapped.get("target_node_uuid", ""), + ) + if edge_key in seen_keys: + continue + seen_keys.add(edge_key) + deduplicated.append(remapped) + + return deduplicated def build_graph_async( self, @@ -109,7 +455,7 @@ def _build_graph_worker( task_id, status=TaskStatus.PROCESSING, progress=5, - message="开始构建图谱..." + message=self._task_message("graph.build_started_worker"), ) # 1. 创建图谱 @@ -117,7 +463,7 @@ def _build_graph_worker( self.task_manager.update_task( task_id, progress=10, - message=f"图谱已创建: {graph_id}" + message=self._task_message("graph.build_graph_created", graph_id=graph_id), ) # 2. 设置本体 @@ -125,7 +471,7 @@ def _build_graph_worker( self.task_manager.update_task( task_id, progress=15, - message="本体已设置" + message=self._task_message("graph.build_ontology_set"), ) # 3. 文本分块 @@ -134,7 +480,7 @@ def _build_graph_worker( self.task_manager.update_task( task_id, progress=20, - message=f"文本已分割为 {total_chunks} 个块" + message=self._task_message("graph.build_chunks_split", total_chunks=total_chunks), ) # 4. 分批发送数据 @@ -151,7 +497,7 @@ def _build_graph_worker( self.task_manager.update_task( task_id, progress=60, - message="等待Zep处理数据..." + message=self._task_message("graph.build_waiting_for_zep"), ) self._wait_for_episodes( @@ -167,7 +513,7 @@ def _build_graph_worker( self.task_manager.update_task( task_id, progress=90, - message="获取图谱信息..." + message=self._task_message("graph.build_fetching_graph_info"), ) graph_info = self._get_graph_info(graph_id) @@ -177,26 +523,31 @@ def _build_graph_worker( "graph_id": graph_id, "graph_info": graph_info.to_dict(), "chunks_processed": total_chunks, - }) + }, locale=self.locale) except Exception as e: import traceback - error_msg = f"{str(e)}\n{traceback.format_exc()}" - self.task_manager.fail_task(task_id, error_msg) + self.logger.error("Graph build worker failed: %s", e) + self.logger.debug(traceback.format_exc()) + self.task_manager.fail_task(task_id, self.format_user_facing_error(e), locale=self.locale) - def create_graph(self, name: str) -> str: + def create_graph(self, name: str, max_retries: Optional[int] = None) -> str: """创建Zep图谱(公开方法)""" graph_id = f"mirofish_{uuid.uuid4().hex[:16]}" - - self.client.graph.create( - graph_id=graph_id, - name=name, - description="MiroFish Social Simulation Graph" + + self._retry_zep_operation( + "create_graph", + lambda: self.client.graph.create( + graph_id=graph_id, + name=name, + description="MiroFish Social Simulation Graph" + ), + max_retries=max_retries, ) return graph_id - def set_ontology(self, graph_id: str, ontology: Dict[str, Any]): + def set_ontology(self, graph_id: str, ontology: Dict[str, Any], max_retries: Optional[int] = None): """设置图谱本体(公开方法)""" import warnings from typing import Optional @@ -215,18 +566,67 @@ def safe_attr_name(attr_name: str) -> str: if attr_name.lower() in RESERVED_NAMES: return f"entity_{attr_name}" return attr_name + + def split_identifier_parts(raw_name: Any) -> List[str]: + text = str(raw_name or "").strip() + if not text: + return [] + pieces = re.findall(r"[A-Z]+(?=[A-Z][a-z]|\d|$)|[A-Z]?[a-z]+|\d+", text.replace("-", "_")) + if not pieces: + pieces = [part for part in re.split(r"[^A-Za-z0-9]+", text) if part] + return [piece for piece in pieces if piece] + + def normalize_entity_type_name(raw_name: Any) -> str: + parts = split_identifier_parts(raw_name) + if not parts: + return "Entity" + return "".join(part[:1].upper() + part[1:].lower() for part in parts) + + def normalize_edge_type_name(raw_name: Any) -> str: + parts = split_identifier_parts(raw_name) + if not parts: + return "RELATED_TO" + return "_".join(part.upper() for part in parts) + + def edge_class_name(raw_name: str) -> str: + return "".join(part[:1].upper() + part[1:].lower() for part in raw_name.split("_") if part) or "RelatedTo" + + def normalized_attributes(owner_name: str, attributes: Any) -> List[Dict[str, str]]: + """Accept string-style attributes from loose LLM output and skip unusable entries.""" + normalized: List[Dict[str, str]] = [] + for attr_def in attributes or []: + if isinstance(attr_def, str): + attr_name = attr_def.strip() + if attr_name: + normalized.append({"name": attr_name, "description": attr_name}) + continue + if not isinstance(attr_def, dict): + self.logger.warning("Skipping invalid ontology attribute for %s: %r", owner_name, attr_def) + continue + + attr_name = str(attr_def.get("name", "")).strip() + if not attr_name: + self.logger.warning("Skipping ontology attribute without name for %s: %r", owner_name, attr_def) + continue + + description = str(attr_def.get("description") or attr_name).strip() or attr_name + normalized.append({"name": attr_name, "description": description}) + return normalized # 动态创建实体类型 entity_types = {} + entity_name_map: Dict[str, str] = {} for entity_def in ontology.get("entity_types", []): - name = entity_def["name"] + raw_name = entity_def["name"] + name = normalize_entity_type_name(raw_name) + entity_name_map[str(raw_name)] = name description = entity_def.get("description", f"A {name} entity.") # 创建属性字典和类型注解(Pydantic v2 需要) attrs = {"__doc__": description} annotations = {} - for attr_def in entity_def.get("attributes", []): + for attr_def in normalized_attributes(name, entity_def.get("attributes", [])): attr_name = safe_attr_name(attr_def["name"]) # 使用安全名称 attr_desc = attr_def.get("description", attr_name) # Zep API 需要 Field 的 description,这是必需的 @@ -243,14 +643,15 @@ def safe_attr_name(attr_name: str) -> str: # 动态创建边类型 edge_definitions = {} for edge_def in ontology.get("edge_types", []): - name = edge_def["name"] + raw_name = edge_def["name"] + name = normalize_edge_type_name(raw_name) description = edge_def.get("description", f"A {name} relationship.") # 创建属性字典和类型注解 attrs = {"__doc__": description} annotations = {} - for attr_def in edge_def.get("attributes", []): + for attr_def in normalized_attributes(name, edge_def.get("attributes", [])): attr_name = safe_attr_name(attr_def["name"]) # 使用安全名称 attr_desc = attr_def.get("description", attr_name) # Zep API 需要 Field 的 description,这是必需的 @@ -260,17 +661,21 @@ def safe_attr_name(attr_name: str) -> str: attrs["__annotations__"] = annotations # 动态创建类 - class_name = ''.join(word.capitalize() for word in name.split('_')) + class_name = edge_class_name(name) edge_class = type(class_name, (EdgeModel,), attrs) edge_class.__doc__ = description # 构建source_targets source_targets = [] for st in edge_def.get("source_targets", []): + source_name = normalize_entity_type_name(st.get("source", "Entity")) + target_name = normalize_entity_type_name(st.get("target", "Entity")) + source_name = entity_name_map.get(str(st.get("source", "")), source_name) + target_name = entity_name_map.get(str(st.get("target", "")), target_name) source_targets.append( EntityEdgeSourceTarget( - source=st.get("source", "Entity"), - target=st.get("target", "Entity") + source=source_name, + target=target_name, ) ) @@ -279,10 +684,14 @@ def safe_attr_name(attr_name: str) -> str: # 调用Zep API设置本体 if entity_types or edge_definitions: - self.client.graph.set_ontology( - graph_ids=[graph_id], - entities=entity_types if entity_types else None, - edges=edge_definitions if edge_definitions else None, + self._retry_zep_operation( + "set_ontology", + lambda: self.client.graph.set_ontology( + graph_ids=[graph_id], + entities=entity_types if entity_types else None, + edges=edge_definitions if edge_definitions else None, + ), + max_retries=max_retries, ) def add_text_batches( @@ -304,7 +713,13 @@ def add_text_batches( if progress_callback: progress = (i + len(batch_chunks)) / total_chunks progress_callback( - f"发送第 {batch_num}/{total_batches} 批数据 ({len(batch_chunks)} 块)...", + tr( + "graph.build_batch_sending", + self.locale, + batch_num=batch_num, + total_batches=total_batches, + chunk_count=len(batch_chunks), + ), progress ) @@ -314,27 +729,38 @@ def add_text_batches( for chunk in batch_chunks ] - # 发送到Zep - try: - batch_result = self.client.graph.add_batch( + def send_batch(): + return self.client.graph.add_batch( graph_id=graph_id, episodes=episodes ) - - # 收集返回的 episode uuid - if batch_result and isinstance(batch_result, list): - for ep in batch_result: - ep_uuid = getattr(ep, 'uuid_', None) or getattr(ep, 'uuid', None) - if ep_uuid: - episode_uuids.append(ep_uuid) - - # 避免请求过快 - time.sleep(1) - - except Exception as e: - if progress_callback: - progress_callback(f"批次 {batch_num} 发送失败: {str(e)}", 0) - raise + + batch_result = self._retry_zep_operation( + f"add_batch[{batch_num}]", + send_batch, + progress_callback=progress_callback, + progress_message=lambda attempt, total, wait_time: ( + tr( + "graph.build_batch_retry", + self.locale, + batch_num=batch_num, + wait_time=wait_time, + attempt=attempt, + total=total, + ) + ), + progress_value=(i + len(batch_chunks)) / total_chunks, + ) + + # 收集返回的 episode uuid + if batch_result and isinstance(batch_result, list): + for ep in batch_result: + ep_uuid = getattr(ep, 'uuid_', None) or getattr(ep, 'uuid', None) + if ep_uuid: + episode_uuids.append(ep_uuid) + + # 避免请求过快 + time.sleep(1) return episode_uuids @@ -347,7 +773,7 @@ def _wait_for_episodes( """等待所有 episode 处理完成(通过查询每个 episode 的 processed 状态)""" if not episode_uuids: if progress_callback: - progress_callback("无需等待(没有 episode)", 1.0) + progress_callback(tr("graph.build_wait_not_required", self.locale), 1.0) return start_time = time.time() @@ -356,13 +782,21 @@ def _wait_for_episodes( total_episodes = len(episode_uuids) if progress_callback: - progress_callback(f"开始等待 {total_episodes} 个文本块处理...", 0) + progress_callback( + tr("graph.build_wait_started", self.locale, total_episodes=total_episodes), + 0, + ) while pending_episodes: if time.time() - start_time > timeout: if progress_callback: progress_callback( - f"部分文本块超时,已完成 {completed_count}/{total_episodes}", + tr( + "graph.build_wait_partial_timeout", + self.locale, + completed_count=completed_count, + total_episodes=total_episodes, + ), completed_count / total_episodes ) break @@ -384,7 +818,14 @@ def _wait_for_episodes( elapsed = int(time.time() - start_time) if progress_callback: progress_callback( - f"Zep处理中... {completed_count}/{total_episodes} 完成, {len(pending_episodes)} 待处理 ({elapsed}秒)", + tr( + "graph.build_wait_progress", + self.locale, + completed_count=completed_count, + total_episodes=total_episodes, + pending_count=len(pending_episodes), + elapsed=elapsed, + ), completed_count / total_episodes if total_episodes > 0 else 0 ) @@ -392,15 +833,23 @@ def _wait_for_episodes( time.sleep(3) # 每3秒检查一次 if progress_callback: - progress_callback(f"处理完成: {completed_count}/{total_episodes}", 1.0) + progress_callback( + tr( + "graph.build_wait_completed", + self.locale, + completed_count=completed_count, + total_episodes=total_episodes, + ), + 1.0, + ) def _get_graph_info(self, graph_id: str) -> GraphInfo: """获取图谱信息""" # 获取节点(分页) - nodes = fetch_all_nodes(self.client, graph_id) + nodes = _fetch_with_optional_locale(fetch_all_nodes, self.client, graph_id, self.locale) # 获取边(分页) - edges = fetch_all_edges(self.client, graph_id) + edges = _fetch_with_optional_locale(fetch_all_edges, self.client, graph_id, self.locale) # 统计实体类型 entity_types = set() @@ -427,8 +876,8 @@ def get_graph_data(self, graph_id: str) -> Dict[str, Any]: Returns: 包含nodes和edges的字典,包括时间信息、属性等详细数据 """ - nodes = fetch_all_nodes(self.client, graph_id) - edges = fetch_all_edges(self.client, graph_id) + nodes = _fetch_with_optional_locale(fetch_all_nodes, self.client, graph_id, self.locale) + edges = _fetch_with_optional_locale(fetch_all_edges, self.client, graph_id, self.locale) # 创建节点映射用于获取节点名称 node_map = {} @@ -451,6 +900,9 @@ def get_graph_data(self, graph_id: str) -> Dict[str, Any]: "created_at": created_at, }) + merged_nodes, uuid_remap = self._merge_duplicate_graph_nodes(nodes_data) + node_name_map = {node["uuid"]: node.get("name") or "" for node in merged_nodes} + edges_data = [] for edge in edges: # 获取时间信息 @@ -485,16 +937,17 @@ def get_graph_data(self, graph_id: str) -> Dict[str, Any]: "expired_at": str(expired_at) if expired_at else None, "episodes": episodes or [], }) + + edges_data = self._deduplicate_graph_edges(edges_data, uuid_remap, node_name_map) return { "graph_id": graph_id, - "nodes": nodes_data, + "nodes": merged_nodes, "edges": edges_data, - "node_count": len(nodes_data), + "node_count": len(merged_nodes), "edge_count": len(edges_data), } def delete_graph(self, graph_id: str): """删除图谱""" self.client.graph.delete(graph_id=graph_id) - diff --git a/backend/app/services/oasis_profile_generator.py b/backend/app/services/oasis_profile_generator.py index 57836c53..25809025 100644 --- a/backend/app/services/oasis_profile_generator.py +++ b/backend/app/services/oasis_profile_generator.py @@ -19,12 +19,62 @@ from zep_cloud.client import Zep from ..config import Config +from ..i18n import tr from ..utils.logger import get_logger +from ..utils.llm_client import LLMClient from .zep_entity_reader import EntityNode, ZepEntityReader logger = get_logger('mirofish.oasis_profile') +def _stringify_profile_value(value: Any) -> str: + if value is None: + return "" + if isinstance(value, str): + return value.strip() + if isinstance(value, dict): + parts = [] + for key, nested_value in value.items(): + normalized = _stringify_profile_value(nested_value) + if normalized: + parts.append(f"{key}: {normalized}") + if parts: + return "; ".join(parts) + return json.dumps(value, ensure_ascii=False) + if isinstance(value, (list, tuple, set)): + parts = [_stringify_profile_value(item) for item in value] + collapsed = [item for item in parts if item] + return ", ".join(collapsed) + return str(value).strip() + + +def _coerce_profile_text(value: Any, fallback: str = "") -> str: + normalized = _stringify_profile_value(value) + return normalized or fallback + + +def _coerce_profile_topics(value: Any) -> List[str]: + if value is None: + return [] + if isinstance(value, (list, tuple, set)): + normalized = [_stringify_profile_value(item) for item in value] + return [item for item in normalized if item] + normalized = _stringify_profile_value(value) + return [normalized] if normalized else [] + + +def _supports_json_mode_error(exc: Exception) -> bool: + text = str(exc).lower() + markers = ( + "response_format", + "json_object", + "unsupported", + "not support", + "invalid parameter", + ) + return any(marker in text for marker in markers) + + @dataclass class OasisAgentProfile: """OASIS Agent Profile数据结构""" @@ -56,6 +106,20 @@ class OasisAgentProfile: source_entity_type: Optional[str] = None created_at: str = field(default_factory=lambda: datetime.now().strftime("%Y-%m-%d")) + + def __post_init__(self) -> None: + self.user_name = _coerce_profile_text(self.user_name, f"user_{self.user_id}") + self.name = _coerce_profile_text(self.name, self.user_name) + self.bio = _coerce_profile_text(self.bio, self.name) + self.persona = _coerce_profile_text( + self.persona, + f"{self.name} is a participant in social discussions.", + ) + self.gender = _coerce_profile_text(self.gender) or None + self.mbti = _coerce_profile_text(self.mbti) or None + self.country = _coerce_profile_text(self.country) or None + self.profession = _coerce_profile_text(self.profession) or None + self.interested_topics = _coerce_profile_topics(self.interested_topics) def to_reddit_format(self) -> Dict[str, Any]: """转换为Reddit平台格式""" @@ -176,6 +240,21 @@ class OasisProfileGenerator: "university", "governmentagency", "organization", "ngo", "mediaoutlet", "company", "institution", "group", "community" ] + + def _text(self, en_text: str, zh_text: str) -> str: + return en_text if getattr(self, "locale", "zh") == "en" else zh_text + + def _default_country(self) -> str: + return "China" if getattr(self, "locale", "zh") == "en" else "中国" + + def _search_query(self, entity_name: str) -> str: + return self._text( + f"All available information, activities, events, relationships, and background about {entity_name}", + f"关于{entity_name}的所有信息、活动、事件、关系和背景", + ) + + def _context_heading(self, en_text: str, zh_text: str) -> str: + return f"### {self._text(en_text, zh_text)}" def __init__( self, @@ -183,14 +262,16 @@ def __init__( base_url: Optional[str] = None, model_name: Optional[str] = None, zep_api_key: Optional[str] = None, - graph_id: Optional[str] = None + graph_id: Optional[str] = None, + locale: str = "zh", ): self.api_key = api_key or Config.LLM_API_KEY self.base_url = base_url or Config.LLM_BASE_URL self.model_name = model_name or Config.LLM_MODEL_NAME + self.locale = "en" if locale == "en" else "zh" if not self.api_key: - raise ValueError("LLM_API_KEY 未配置") + raise ValueError(tr("config.llm_key_missing", self.locale)) self.client = OpenAI( api_key=self.api_key, @@ -206,7 +287,51 @@ def __init__( try: self.zep_client = Zep(api_key=self.zep_api_key) except Exception as e: - logger.warning(f"Zep客户端初始化失败: {e}") + logger.warning( + self._text( + f"Failed to initialize the Zep client: {e}", + f"Zep客户端初始化失败: {e}", + ) + ) + + def _request_json_completion( + self, + messages: List[Dict[str, str]], + temperature: float, + ) -> Dict[str, Any]: + kwargs = { + "model": self.model_name, + "messages": messages, + "temperature": temperature, + "response_format": {"type": "json_object"}, + } + + try: + response = self.client.chat.completions.create(**kwargs) + except Exception as exc: + if not _supports_json_mode_error(exc): + raise + + logger.warning(tr("llm.json_mode_retry", self.locale)) + kwargs.pop("response_format", None) + response = self.client.chat.completions.create(**kwargs) + + content = response.choices[0].message.content or "" + finish_reason = response.choices[0].finish_reason + if finish_reason == 'length': + logger.warning( + self._text( + "LLM output was truncated; attempting to repair the JSON payload...", + "LLM输出被截断, 尝试修复...", + ) + ) + content = self._fix_truncated_json(content) + + parsed_content = LLMClient._extract_json_payload(content) + return { + "content": parsed_content, + "finish_reason": finish_reason, + } def generate_profile_from_entity( self, @@ -310,10 +435,10 @@ def _search_zep_for_entity(self, entity: EntityNode) -> Dict[str, Any]: # 必须有graph_id才能进行搜索 if not self.graph_id: - logger.debug(f"跳过Zep检索:未设置graph_id") + logger.debug(self._text("Skipping Zep lookup: graph_id is not set", "跳过Zep检索:未设置graph_id")) return results - comprehensive_query = f"关于{entity_name}的所有信息、活动、事件、关系和背景" + comprehensive_query = self._search_query(entity_name) def search_edges(): """搜索边(事实/关系)- 带重试机制""" @@ -333,11 +458,21 @@ def search_edges(): except Exception as e: last_exception = e if attempt < max_retries - 1: - logger.debug(f"Zep边搜索第 {attempt + 1} 次失败: {str(e)[:80]}, 重试中...") + logger.debug( + self._text( + f"Zep edge search attempt {attempt + 1} failed: {str(e)[:80]}, retrying...", + f"Zep边搜索第 {attempt + 1} 次失败: {str(e)[:80]}, 重试中...", + ) + ) time.sleep(delay) delay *= 2 else: - logger.debug(f"Zep边搜索在 {max_retries} 次尝试后仍失败: {e}") + logger.debug( + self._text( + f"Zep edge search still failed after {max_retries} attempts: {e}", + f"Zep边搜索在 {max_retries} 次尝试后仍失败: {e}", + ) + ) return None def search_nodes(): @@ -358,11 +493,21 @@ def search_nodes(): except Exception as e: last_exception = e if attempt < max_retries - 1: - logger.debug(f"Zep节点搜索第 {attempt + 1} 次失败: {str(e)[:80]}, 重试中...") + logger.debug( + self._text( + f"Zep node search attempt {attempt + 1} failed: {str(e)[:80]}, retrying...", + f"Zep节点搜索第 {attempt + 1} 次失败: {str(e)[:80]}, 重试中...", + ) + ) time.sleep(delay) delay *= 2 else: - logger.debug(f"Zep节点搜索在 {max_retries} 次尝试后仍失败: {e}") + logger.debug( + self._text( + f"Zep node search still failed after {max_retries} attempts: {e}", + f"Zep节点搜索在 {max_retries} 次尝试后仍失败: {e}", + ) + ) return None try: @@ -390,23 +535,49 @@ def search_nodes(): if hasattr(node, 'summary') and node.summary: all_summaries.add(node.summary) if hasattr(node, 'name') and node.name and node.name != entity_name: - all_summaries.add(f"相关实体: {node.name}") + all_summaries.add( + self._text( + f"Related entity: {node.name}", + f"相关实体: {node.name}", + ) + ) results["node_summaries"] = list(all_summaries) # 构建综合上下文 context_parts = [] if results["facts"]: - context_parts.append("事实信息:\n" + "\n".join(f"- {f}" for f in results["facts"][:20])) + context_parts.append( + f"{self._text('Facts', '事实信息')}:\n" + + "\n".join(f"- {f}" for f in results["facts"][:20]) + ) if results["node_summaries"]: - context_parts.append("相关实体:\n" + "\n".join(f"- {s}" for s in results["node_summaries"][:10])) + context_parts.append( + f"{self._text('Related entities', '相关实体')}:\n" + + "\n".join(f"- {s}" for s in results["node_summaries"][:10]) + ) results["context"] = "\n\n".join(context_parts) - logger.info(f"Zep混合检索完成: {entity_name}, 获取 {len(results['facts'])} 条事实, {len(results['node_summaries'])} 个相关节点") + logger.info( + self._text( + f"Completed Zep hybrid retrieval: {entity_name}, fetched {len(results['facts'])} facts and {len(results['node_summaries'])} related nodes", + f"Zep混合检索完成: {entity_name}, 获取 {len(results['facts'])} 条事实, {len(results['node_summaries'])} 个相关节点", + ) + ) except concurrent.futures.TimeoutError: - logger.warning(f"Zep检索超时 ({entity_name})") + logger.warning( + self._text( + f"Zep retrieval timed out ({entity_name})", + f"Zep检索超时 ({entity_name})", + ) + ) except Exception as e: - logger.warning(f"Zep检索失败 ({entity_name}): {e}") + logger.warning( + self._text( + f"Zep retrieval failed ({entity_name}): {e}", + f"Zep检索失败 ({entity_name}): {e}", + ) + ) return results @@ -428,7 +599,9 @@ def _build_entity_context(self, entity: EntityNode) -> str: if value and str(value).strip(): attrs.append(f"- {key}: {value}") if attrs: - context_parts.append("### 实体属性\n" + "\n".join(attrs)) + context_parts.append( + self._context_heading("Entity attributes", "实体属性") + "\n" + "\n".join(attrs) + ) # 2. 添加相关边信息(事实/关系) existing_facts = set() @@ -444,12 +617,26 @@ def _build_entity_context(self, entity: EntityNode) -> str: existing_facts.add(fact) elif edge_name: if direction == "outgoing": - relationships.append(f"- {entity.name} --[{edge_name}]--> (相关实体)") + relationships.append( + self._text( + f"- {entity.name} --[{edge_name}]--> (related entity)", + f"- {entity.name} --[{edge_name}]--> (相关实体)", + ) + ) else: - relationships.append(f"- (相关实体) --[{edge_name}]--> {entity.name}") + relationships.append( + self._text( + f"- (related entity) --[{edge_name}]--> {entity.name}", + f"- (相关实体) --[{edge_name}]--> {entity.name}", + ) + ) if relationships: - context_parts.append("### 相关事实和关系\n" + "\n".join(relationships)) + context_parts.append( + self._context_heading("Relevant facts and relationships", "相关事实和关系") + + "\n" + + "\n".join(relationships) + ) # 3. 添加关联节点的详细信息 if entity.related_nodes: @@ -469,7 +656,11 @@ def _build_entity_context(self, entity: EntityNode) -> str: related_info.append(f"- **{node_name}**{label_str}") if related_info: - context_parts.append("### 关联实体信息\n" + "\n".join(related_info)) + context_parts.append( + self._context_heading("Related entity information", "关联实体信息") + + "\n" + + "\n".join(related_info) + ) # 4. 使用Zep混合检索获取更丰富的信息 zep_results = self._search_zep_for_entity(entity) @@ -478,10 +669,18 @@ def _build_entity_context(self, entity: EntityNode) -> str: # 去重:排除已存在的事实 new_facts = [f for f in zep_results["facts"] if f not in existing_facts] if new_facts: - context_parts.append("### Zep检索到的事实信息\n" + "\n".join(f"- {f}" for f in new_facts[:15])) + context_parts.append( + self._context_heading("Facts retrieved from Zep", "Zep检索到的事实信息") + + "\n" + + "\n".join(f"- {f}" for f in new_facts[:15]) + ) if zep_results.get("node_summaries"): - context_parts.append("### Zep检索到的相关节点\n" + "\n".join(f"- {s}" for s in zep_results["node_summaries"][:10])) + context_parts.append( + self._context_heading("Related nodes retrieved from Zep", "Zep检索到的相关节点") + + "\n" + + "\n".join(f"- {s}" for s in zep_results["node_summaries"][:10]) + ) return "\n\n".join(context_parts) @@ -526,24 +725,14 @@ def _generate_profile_with_llm( for attempt in range(max_attempts): try: - response = self.client.chat.completions.create( - model=self.model_name, + completion = self._request_json_completion( messages=[ {"role": "system", "content": self._get_system_prompt(is_individual)}, - {"role": "user", "content": prompt} + {"role": "user", "content": prompt}, ], - response_format={"type": "json_object"}, - temperature=0.7 - (attempt * 0.1) # 每次重试降低温度 - # 不设置max_tokens,让LLM自由发挥 + temperature=0.7 - (attempt * 0.1), ) - - content = response.choices[0].message.content - - # 检查是否被截断(finish_reason不是'stop') - finish_reason = response.choices[0].finish_reason - if finish_reason == 'length': - logger.warning(f"LLM输出被截断 (attempt {attempt+1}), 尝试修复...") - content = self._fix_truncated_json(content) + content = completion["content"] # 尝试解析JSON try: @@ -553,12 +742,20 @@ def _generate_profile_with_llm( if "bio" not in result or not result["bio"]: result["bio"] = entity_summary[:200] if entity_summary else f"{entity_type}: {entity_name}" if "persona" not in result or not result["persona"]: - result["persona"] = entity_summary or f"{entity_name}是一个{entity_type}。" + result["persona"] = entity_summary or self._text( + f"{entity_name} is a {entity_type}.", + f"{entity_name}是一个{entity_type}。", + ) return result except json.JSONDecodeError as je: - logger.warning(f"JSON解析失败 (attempt {attempt+1}): {str(je)[:80]}") + logger.warning( + self._text( + f"JSON parsing failed (attempt {attempt+1}): {str(je)[:80]}", + f"JSON解析失败 (attempt {attempt+1}): {str(je)[:80]}", + ) + ) # 尝试修复JSON result = self._try_fix_json(content, entity_name, entity_type, entity_summary) @@ -569,12 +766,22 @@ def _generate_profile_with_llm( last_error = je except Exception as e: - logger.warning(f"LLM调用失败 (attempt {attempt+1}): {str(e)[:80]}") + logger.warning( + self._text( + f"LLM call failed (attempt {attempt+1}): {str(e)[:80]}", + f"LLM调用失败 (attempt {attempt+1}): {str(e)[:80]}", + ) + ) last_error = e import time time.sleep(1 * (attempt + 1)) # 指数退避 - logger.warning(f"LLM生成人设失败({max_attempts}次尝试): {last_error}, 使用规则生成") + logger.warning( + self._text( + f"LLM profile generation failed after {max_attempts} attempts: {last_error}; falling back to rule-based generation", + f"LLM生成人设失败({max_attempts}次尝试): {last_error}, 使用规则生成", + ) + ) return self._generate_profile_rule_based( entity_name, entity_type, entity_summary, entity_attributes ) @@ -642,7 +849,7 @@ def fix_string_newlines(match): result = json.loads(json_str) result["_fixed"] = True return result - except: + except Exception: pass # 6. 尝试从内容中提取部分信息 @@ -650,11 +857,19 @@ def fix_string_newlines(match): persona_match = re.search(r'"persona"\s*:\s*"([^"]*)', content) # 可能被截断 bio = bio_match.group(1) if bio_match else (entity_summary[:200] if entity_summary else f"{entity_type}: {entity_name}") - persona = persona_match.group(1) if persona_match else (entity_summary or f"{entity_name}是一个{entity_type}。") + persona = persona_match.group(1) if persona_match else ( + entity_summary + or self._text(f"{entity_name} is a {entity_type}.", f"{entity_name}是一个{entity_type}。") + ) # 如果提取到了有意义的内容,标记为已修复 if bio_match or persona_match: - logger.info(f"从损坏的JSON中提取了部分信息") + logger.info( + self._text( + "Recovered partial profile fields from malformed JSON", + "从损坏的JSON中提取了部分信息", + ) + ) return { "bio": bio, "persona": persona, @@ -662,14 +877,28 @@ def fix_string_newlines(match): } # 7. 完全失败,返回基础结构 - logger.warning(f"JSON修复失败,返回基础结构") + logger.warning( + self._text( + "Failed to repair malformed JSON; falling back to the base profile structure", + "JSON修复失败,返回基础结构", + ) + ) return { "bio": entity_summary[:200] if entity_summary else f"{entity_type}: {entity_name}", - "persona": entity_summary or f"{entity_name}是一个{entity_type}。" + "persona": entity_summary or self._text( + f"{entity_name} is a {entity_type}.", + f"{entity_name}是一个{entity_type}。", + ) } def _get_system_prompt(self, is_individual: bool) -> str: """获取系统提示词""" + if self.locale == "en": + return ( + "You are an expert at generating detailed social-media personas for public-opinion simulations. " + "Return valid JSON only, and do not include unescaped newline characters inside string values. " + "Write all user-facing text fields in English." + ) base_prompt = "你是社交媒体用户画像生成专家。生成详细、真实的人设用于舆论模拟,最大程度还原已有现实情况。必须返回有效的JSON格式,所有字符串值不能包含未转义的换行符。使用中文。" return base_prompt @@ -682,10 +911,43 @@ def _build_individual_persona_prompt( context: str ) -> str: """构建个人实体的详细人设提示词""" + + if self.locale == "en": + attrs_str = json.dumps(entity_attributes, ensure_ascii=False) if entity_attributes else "None" + context_str = context[:3000] if context else "No additional context provided" + else: + attrs_str = json.dumps(entity_attributes, ensure_ascii=False) if entity_attributes else "无" + context_str = context[:3000] if context else "无额外上下文" - attrs_str = json.dumps(entity_attributes, ensure_ascii=False) if entity_attributes else "无" - context_str = context[:3000] if context else "无额外上下文" - + if self.locale == "en": + return f"""Generate a detailed social-media persona for this entity and stay as faithful as possible to the available context. + +Entity name: {entity_name} +Entity type: {entity_type} +Entity summary: {entity_summary} +Entity attributes: {attrs_str} + +Context: +{context_str} + +Return JSON with these fields: + +1. bio: social-media bio, about 200 characters +2. persona: detailed persona description (plain text, about 2000 characters) +3. age: integer age +4. gender: must be "male" or "female" +5. mbti: MBTI type such as INTJ or ENFP +6. country: country name in English +7. profession: profession +8. interested_topics: array of interested topics + +Important: +- Every field value must be a string, number, or array without embedded newlines +- persona must be one continuous paragraph +- Use English for all user-facing fields except gender values +- Keep the content consistent with the source entity information +""" + return f"""为实体生成详细的社交媒体用户人设,最大程度还原已有现实情况。 实体名称: {entity_name} @@ -731,10 +993,43 @@ def _build_group_persona_prompt( context: str ) -> str: """构建群体/机构实体的详细人设提示词""" + + if self.locale == "en": + attrs_str = json.dumps(entity_attributes, ensure_ascii=False) if entity_attributes else "None" + context_str = context[:3000] if context else "No additional context provided" + else: + attrs_str = json.dumps(entity_attributes, ensure_ascii=False) if entity_attributes else "无" + context_str = context[:3000] if context else "无额外上下文" - attrs_str = json.dumps(entity_attributes, ensure_ascii=False) if entity_attributes else "无" - context_str = context[:3000] if context else "无额外上下文" - + if self.locale == "en": + return f"""Generate a detailed social-media account persona for this organization or group, staying faithful to the available context. + +Entity name: {entity_name} +Entity type: {entity_type} +Entity summary: {entity_summary} +Entity attributes: {attrs_str} + +Context: +{context_str} + +Return JSON with these fields: + +1. bio: official account bio, about 200 characters +2. persona: detailed account description (plain text, about 2000 characters) +3. age: fixed integer 30 +4. gender: fixed string "other" +5. mbti: MBTI type describing account style +6. country: country name in English +7. profession: organization function description +8. interested_topics: array of focus areas + +Important: +- Every field value must be a string, number, or array and must not be null +- persona must be one continuous paragraph without embedded newlines +- Use English for all user-facing fields except the fixed gender value +- Keep the account voice aligned with the entity identity +""" + return f"""为机构/群体实体生成详细的社交媒体账号设定,最大程度还原已有现实情况。 实体名称: {entity_name} @@ -784,63 +1079,120 @@ def _generate_profile_rule_based( if entity_type_lower in ["student", "alumni"]: return { - "bio": f"{entity_type} with interests in academics and social issues.", - "persona": f"{entity_name} is a {entity_type.lower()} who is actively engaged in academic and social discussions. They enjoy sharing perspectives and connecting with peers.", + "bio": self._text( + f"{entity_type} with interests in academics and social issues.", + f"关注学术与社会议题的{entity_type}", + ), + "persona": self._text( + ( + f"{entity_name} is a {entity_type.lower()} who is actively engaged in academic " + "and social discussions. They enjoy sharing perspectives and connecting with peers." + ), + f"{entity_name}是一名积极参与学术与社会讨论的{entity_type},乐于分享观点并与同伴交流。", + ), "age": random.randint(18, 30), "gender": random.choice(["male", "female"]), "mbti": random.choice(self.MBTI_TYPES), "country": random.choice(self.COUNTRIES), - "profession": "Student", - "interested_topics": ["Education", "Social Issues", "Technology"], + "profession": self._text("Student", "学生"), + "interested_topics": self._text( + ["Education", "Social Issues", "Technology"], + ["教育", "社会议题", "科技"], + ), } elif entity_type_lower in ["publicfigure", "expert", "faculty"]: return { - "bio": f"Expert and thought leader in their field.", - "persona": f"{entity_name} is a recognized {entity_type.lower()} who shares insights and opinions on important matters. They are known for their expertise and influence in public discourse.", + "bio": self._text( + "Expert and thought leader in their field.", + "所在领域的专家与意见领袖。", + ), + "persona": self._text( + ( + f"{entity_name} is a recognized {entity_type.lower()} who shares insights and opinions " + "on important matters. They are known for their expertise and influence in public discourse." + ), + f"{entity_name}是一位受到认可的{entity_type},会围绕重要议题分享见解与观点,并因其专业能力与公共影响力而受到关注。", + ), "age": random.randint(35, 60), "gender": random.choice(["male", "female"]), "mbti": random.choice(["ENTJ", "INTJ", "ENTP", "INTP"]), "country": random.choice(self.COUNTRIES), - "profession": entity_attributes.get("occupation", "Expert"), - "interested_topics": ["Politics", "Economics", "Culture & Society"], + "profession": entity_attributes.get("occupation") or self._text("Expert", "专家"), + "interested_topics": self._text( + ["Politics", "Economics", "Culture & Society"], + ["政治", "经济", "社会文化"], + ), } elif entity_type_lower in ["mediaoutlet", "socialmediaplatform"]: return { - "bio": f"Official account for {entity_name}. News and updates.", - "persona": f"{entity_name} is a media entity that reports news and facilitates public discourse. The account shares timely updates and engages with the audience on current events.", + "bio": self._text( + f"Official account for {entity_name}. News and updates.", + f"{entity_name}的官方账号,发布新闻与动态。", + ), + "persona": self._text( + ( + f"{entity_name} is a media entity that reports news and facilitates public discourse. " + "The account shares timely updates and engages with the audience on current events." + ), + f"{entity_name}是一个报道新闻并促进公共讨论的媒体主体,该账号会及时发布动态并与受众互动。", + ), "age": 30, # 机构虚拟年龄 "gender": "other", # 机构使用other "mbti": "ISTJ", # 机构风格:严谨保守 - "country": "中国", - "profession": "Media", - "interested_topics": ["General News", "Current Events", "Public Affairs"], + "country": self._default_country(), + "profession": self._text("Media", "媒体"), + "interested_topics": self._text( + ["General News", "Current Events", "Public Affairs"], + ["综合新闻", "时事动态", "公共事务"], + ), } elif entity_type_lower in ["university", "governmentagency", "ngo", "organization"]: return { - "bio": f"Official account of {entity_name}.", - "persona": f"{entity_name} is an institutional entity that communicates official positions, announcements, and engages with stakeholders on relevant matters.", + "bio": self._text( + f"Official account of {entity_name}.", + f"{entity_name}的官方账号。", + ), + "persona": self._text( + ( + f"{entity_name} is an institutional entity that communicates official positions, " + "announcements, and engages with stakeholders on relevant matters." + ), + f"{entity_name}是一个机构主体,会发布官方立场与公告,并围绕相关事务与利益相关方互动。", + ), "age": 30, # 机构虚拟年龄 "gender": "other", # 机构使用other "mbti": "ISTJ", # 机构风格:严谨保守 - "country": "中国", + "country": self._default_country(), "profession": entity_type, - "interested_topics": ["Public Policy", "Community", "Official Announcements"], + "interested_topics": self._text( + ["Public Policy", "Community", "Official Announcements"], + ["公共政策", "社区事务", "官方公告"], + ), } else: # 默认人设 return { - "bio": entity_summary[:150] if entity_summary else f"{entity_type}: {entity_name}", - "persona": entity_summary or f"{entity_name} is a {entity_type.lower()} participating in social discussions.", + "bio": entity_summary[:150] if entity_summary else self._text( + f"{entity_type}: {entity_name}", + f"{entity_type}:{entity_name}", + ), + "persona": entity_summary or self._text( + f"{entity_name} is a {entity_type.lower()} participating in social discussions.", + f"{entity_name}是一名参与社会讨论的{entity_type}。", + ), "age": random.randint(25, 50), "gender": random.choice(["male", "female"]), "mbti": random.choice(self.MBTI_TYPES), "country": random.choice(self.COUNTRIES), "profession": entity_type, - "interested_topics": ["General", "Social Issues"], + "interested_topics": self._text( + ["General", "Social Issues"], + ["综合", "社会议题"], + ), } def set_graph_id(self, graph_id: str): @@ -913,7 +1265,12 @@ def save_profiles_realtime(): writer.writeheader() writer.writerows(profiles_data) except Exception as e: - logger.warning(f"实时保存 profiles 失败: {e}") + logger.warning( + self._text( + f"Failed to save profiles incrementally: {e}", + f"实时保存 profiles 失败: {e}", + ) + ) def generate_single_profile(idx: int, entity: EntityNode) -> tuple: """生成单个profile的工作函数""" @@ -932,7 +1289,12 @@ def generate_single_profile(idx: int, entity: EntityNode) -> tuple: return idx, profile, None except Exception as e: - logger.error(f"生成实体 {entity.name} 的人设失败: {str(e)}") + logger.error( + self._text( + f"Failed to generate a profile for entity {entity.name}: {str(e)}", + f"生成实体 {entity.name} 的人设失败: {str(e)}", + ) + ) # 创建一个基础profile fallback_profile = OasisAgentProfile( user_id=idx, @@ -945,9 +1307,19 @@ def generate_single_profile(idx: int, entity: EntityNode) -> tuple: ) return idx, fallback_profile, str(e) - logger.info(f"开始并行生成 {total} 个Agent人设(并行数: {parallel_count})...") + logger.info( + self._text( + f"Starting parallel generation for {total} agent profiles (parallelism: {parallel_count})...", + f"开始并行生成 {total} 个Agent人设(并行数: {parallel_count})...", + ) + ) print(f"\n{'='*60}") - print(f"开始生成Agent人设 - 共 {total} 个实体,并行数: {parallel_count}") + print( + self._text( + f"Starting agent-profile generation for {total} entities (parallelism: {parallel_count})", + f"开始生成Agent人设 - 共 {total} 个实体,并行数: {parallel_count}", + ) + ) print(f"{'='*60}\n") # 使用线程池并行执行 @@ -978,16 +1350,34 @@ def generate_single_profile(idx: int, entity: EntityNode) -> tuple: progress_callback( current, total, - f"已完成 {current}/{total}: {entity.name}({entity_type})" + self._text( + f"Completed {current}/{total}: {entity.name} ({entity_type})", + f"已完成 {current}/{total}: {entity.name}({entity_type})", + ) ) if error: - logger.warning(f"[{current}/{total}] {entity.name} 使用备用人设: {error}") + logger.warning( + self._text( + f"[{current}/{total}] {entity.name} used a fallback profile: {error}", + f"[{current}/{total}] {entity.name} 使用备用人设: {error}", + ) + ) else: - logger.info(f"[{current}/{total}] 成功生成人设: {entity.name} ({entity_type})") + logger.info( + self._text( + f"[{current}/{total}] Successfully generated a profile: {entity.name} ({entity_type})", + f"[{current}/{total}] 成功生成人设: {entity.name} ({entity_type})", + ) + ) except Exception as e: - logger.error(f"处理实体 {entity.name} 时发生异常: {str(e)}") + logger.error( + self._text( + f"Unexpected error while processing entity {entity.name}: {str(e)}", + f"处理实体 {entity.name} 时发生异常: {str(e)}", + ) + ) with lock: completed_count[0] += 1 profiles[idx] = OasisAgentProfile( @@ -1003,7 +1393,12 @@ def generate_single_profile(idx: int, entity: EntityNode) -> tuple: save_profiles_realtime() print(f"\n{'='*60}") - print(f"人设生成完成!共生成 {len([p for p in profiles if p])} 个Agent") + print( + self._text( + f"Profile generation completed. Generated {len([p for p in profiles if p])} agents in total", + f"人设生成完成!共生成 {len([p for p in profiles if p])} 个Agent", + ) + ) print(f"{'='*60}\n") return profiles @@ -1013,24 +1408,30 @@ def _print_generated_profile(self, entity_name: str, entity_type: str, profile: separator = "-" * 70 # 构建完整输出内容(不截断) - topics_str = ', '.join(profile.interested_topics) if profile.interested_topics else '无' + topics_str = ', '.join(profile.interested_topics) if profile.interested_topics else self._text('none', '无') output_lines = [ f"\n{separator}", - f"[已生成] {entity_name} ({entity_type})", + self._text(f"[Generated] {entity_name} ({entity_type})", f"[已生成] {entity_name} ({entity_type})"), f"{separator}", - f"用户名: {profile.user_name}", + self._text(f"Username: {profile.user_name}", f"用户名: {profile.user_name}"), f"", - f"【简介】", + self._text("Bio", "【简介】"), f"{profile.bio}", f"", - f"【详细人设】", + self._text("Detailed Persona", "【详细人设】"), f"{profile.persona}", f"", - f"【基本属性】", - f"年龄: {profile.age} | 性别: {profile.gender} | MBTI: {profile.mbti}", - f"职业: {profile.profession} | 国家: {profile.country}", - f"兴趣话题: {topics_str}", + self._text("Core Attributes", "【基本属性】"), + self._text( + f"Age: {profile.age} | Gender: {profile.gender} | MBTI: {profile.mbti}", + f"年龄: {profile.age} | 性别: {profile.gender} | MBTI: {profile.mbti}", + ), + self._text( + f"Profession: {profile.profession} | Country: {profile.country}", + f"职业: {profile.profession} | 国家: {profile.country}", + ), + self._text(f"Interested Topics: {topics_str}", f"兴趣话题: {topics_str}"), separator ] @@ -1092,15 +1493,17 @@ def _save_twitter_csv(self, profiles: List[OasisAgentProfile], file_path: str): # 写入数据行 for idx, profile in enumerate(profiles): + bio = _coerce_profile_text(profile.bio, profile.name) + persona = _coerce_profile_text(profile.persona) # user_char: 完整人设(bio + persona),用于LLM系统提示 - user_char = profile.bio - if profile.persona and profile.persona != profile.bio: - user_char = f"{profile.bio} {profile.persona}" + user_char = bio + if persona and persona != bio: + user_char = f"{bio} {persona}" # 处理换行符(CSV中用空格替代) user_char = user_char.replace('\n', ' ').replace('\r', ' ') # description: 简短简介,用于外部显示 - description = profile.bio.replace('\n', ' ').replace('\r', ' ') + description = bio.replace('\n', ' ').replace('\r', ' ') row = [ idx, # user_id: 从0开始的顺序ID @@ -1111,7 +1514,12 @@ def _save_twitter_csv(self, profiles: List[OasisAgentProfile], file_path: str): ] writer.writerow(row) - logger.info(f"已保存 {len(profiles)} 个Twitter Profile到 {file_path} (OASIS CSV格式)") + logger.info( + self._text( + f"Saved {len(profiles)} Twitter profiles to {file_path} (OASIS CSV format)", + f"已保存 {len(profiles)} 个Twitter Profile到 {file_path} (OASIS CSV格式)", + ) + ) def _normalize_gender(self, gender: Optional[str]) -> str: """ @@ -1121,6 +1529,11 @@ def _normalize_gender(self, gender: Optional[str]) -> str: """ if not gender: return "other" + + if not isinstance(gender, str): + gender = _coerce_profile_text(gender) + if not gender: + return "other" gender_lower = gender.lower().strip() @@ -1158,34 +1571,45 @@ def _save_reddit_json(self, profiles: List[OasisAgentProfile], file_path: str): """ data = [] for idx, profile in enumerate(profiles): + bio = _coerce_profile_text(profile.bio, f"{profile.name}") + persona = _coerce_profile_text( + profile.persona, + f"{profile.name} is a participant in social discussions.", + ) + country = _coerce_profile_text(profile.country, self._default_country()) # 使用与 to_reddit_format() 一致的格式 item = { "user_id": profile.user_id if profile.user_id is not None else idx, # 关键:必须包含 user_id "username": profile.user_name, "name": profile.name, - "bio": profile.bio[:150] if profile.bio else f"{profile.name}", - "persona": profile.persona or f"{profile.name} is a participant in social discussions.", + "bio": bio[:150] if bio else f"{profile.name}", + "persona": persona, "karma": profile.karma if profile.karma else 1000, "created_at": profile.created_at, # OASIS必需字段 - 确保都有默认值 "age": profile.age if profile.age else 30, "gender": self._normalize_gender(profile.gender), "mbti": profile.mbti if profile.mbti else "ISTJ", - "country": profile.country if profile.country else "中国", + "country": country, } # 可选字段 if profile.profession: - item["profession"] = profile.profession + item["profession"] = _coerce_profile_text(profile.profession) if profile.interested_topics: - item["interested_topics"] = profile.interested_topics + item["interested_topics"] = _coerce_profile_topics(profile.interested_topics) data.append(item) with open(file_path, 'w', encoding='utf-8') as f: json.dump(data, f, ensure_ascii=False, indent=2) - logger.info(f"已保存 {len(profiles)} 个Reddit Profile到 {file_path} (JSON格式,包含user_id字段)") + logger.info( + self._text( + f"Saved {len(profiles)} Reddit profiles to {file_path} (JSON format with user_id)", + f"已保存 {len(profiles)} 个Reddit Profile到 {file_path} (JSON格式,包含user_id字段)", + ) + ) # 保留旧方法名作为别名,保持向后兼容 def save_profiles_to_json( @@ -1195,6 +1619,10 @@ def save_profiles_to_json( platform: str = "reddit" ): """[已废弃] 请使用 save_profiles() 方法""" - logger.warning("save_profiles_to_json已废弃,请使用save_profiles方法") + logger.warning( + self._text( + "save_profiles_to_json is deprecated; use save_profiles instead", + "save_profiles_to_json已废弃,请使用save_profiles方法", + ) + ) self.save_profiles(profiles, file_path, platform) - diff --git a/backend/app/services/ontology_generator.py b/backend/app/services/ontology_generator.py index 2d3e39bd..3f7b887c 100644 --- a/backend/app/services/ontology_generator.py +++ b/backend/app/services/ontology_generator.py @@ -4,6 +4,7 @@ """ import json +import re from typing import Dict, Any, List, Optional from ..utils.llm_client import LLMClient @@ -154,6 +155,151 @@ - COMPETES_WITH: 竞争 """ +ONTOLOGY_SYSTEM_PROMPT_EN = """You are a professional knowledge-graph ontology designer. Analyze the provided source text and simulation requirement, then design entity types and relationship types suitable for a **social-media public-opinion simulation**. + +**Important: output valid JSON only. Do not output any other text.** + +## Core task background + +We are building a **social-media public-opinion simulation system**. In this system: +- Each entity should be a real actor, account, or organization that can speak and interact on social media +- Entities influence, repost, comment on, and respond to each other +- We need to simulate reactions and information spread around a real-world event or topic + +Therefore, **entities must be real-world actors that can speak or interact on social media**. + +**Allowed examples**: +- Specific people (public figures, involved parties, opinion leaders, experts, ordinary individuals) +- Companies and businesses (including official brand accounts) +- Organizations and institutions (universities, associations, NGOs, unions, etc.) +- Government departments and regulators +- Media organizations (newspapers, TV stations, self-media accounts, websites) +- Social-media platforms themselves +- Representative groups (alumni groups, fan communities, advocacy groups, etc.) + +**Not allowed**: +- Abstract concepts (such as "public opinion", "emotion", or "trend") +- Topics/themes (such as "academic integrity" or "education reform") +- Positions/stances (such as "supporters" or "opponents") + +## Output format + +Return JSON in the following shape: + +```json +{ + "entity_types": [ + { + "name": "Entity type name (English, PascalCase)", + "description": "Short description (English, under 100 characters)", + "attributes": [ + { + "name": "Attribute name (English, snake_case)", + "type": "text", + "description": "Attribute description" + } + ], + "examples": ["Example entity 1", "Example entity 2"] + } + ], + "edge_types": [ + { + "name": "Relationship type name (English, UPPER_SNAKE_CASE)", + "description": "Short description (English, under 100 characters)", + "source_targets": [ + {"source": "Source entity type", "target": "Target entity type"} + ], + "attributes": [] + } + ], + "analysis_summary": "Brief analysis summary of the text content (English)" +} +``` + +## Design rules (very important) + +### 1. Entity type design + +**Quantity requirement: exactly 10 entity types** + +**Hierarchy requirement (must include both specific types and fallback types):** + +Your 10 entity types must include: + +A. **Fallback types (required, and they must be the last 2 items)**: + - `Person`: fallback type for any natural person who does not fit a more specific person type + - `Organization`: fallback type for any organization that does not fit a more specific organization type + +B. **Specific types (8 items, designed from the text)**: + - Create more specific types for the major actors appearing in the material + - Example for an academic incident: `Student`, `Professor`, `University` + - Example for a business story: `Company`, `CEO`, `Employee` + +**Why fallback types are needed**: +- Real documents mention many people such as school teachers, passers-by, or anonymous users +- If there is no specialized type, they should fall back to `Person` +- Likewise, small organizations or temporary groups should fall back to `Organization` + +**Specific type rules**: +- Identify the high-frequency or critical role categories in the text +- Each specific type should have a clear boundary and avoid overlap +- `description` must clearly explain how the type differs from the fallback type + +### 2. Relationship type design + +- Quantity: 6-10 +- Relationships should reflect realistic social-media/public-opinion interactions +- Ensure the `source_targets` combinations cover the entity types you defined + +### 3. Attribute design + +- Each entity type should have 1-3 key attributes +- **Important**: do not use reserved attribute names such as `name`, `uuid`, `group_id`, `created_at`, or `summary` +- Prefer names like `full_name`, `title`, `role`, `position`, `location`, or `description` + +## Reference entity types + +**Specific person-like types**: +- Student +- Professor +- Journalist +- Celebrity +- Executive +- Official +- Lawyer +- Doctor + +**Fallback person type**: +- Person + +**Specific organization-like types**: +- University +- Company +- GovernmentAgency +- MediaOutlet +- Hospital +- School +- NGO + +**Fallback organization type**: +- Organization + +## Reference relationship types + +- WORKS_FOR +- STUDIES_AT +- AFFILIATED_WITH +- REPRESENTS +- REGULATES +- REPORTS_ON +- COMMENTS_ON +- RESPONDS_TO +- SUPPORTS +- OPPOSES +- COLLABORATES_WITH +- COMPETES_WITH +""" + class OntologyGenerator: """ @@ -161,8 +307,9 @@ class OntologyGenerator: 分析文本内容,生成实体和关系类型定义 """ - def __init__(self, llm_client: Optional[LLMClient] = None): + def __init__(self, llm_client: Optional[LLMClient] = None, locale: str = "zh"): self.llm_client = llm_client or LLMClient() + self.locale = "en" if locale == "en" else "zh" def generate( self, @@ -189,7 +336,7 @@ def generate( ) messages = [ - {"role": "system", "content": ONTOLOGY_SYSTEM_PROMPT}, + {"role": "system", "content": self._build_system_prompt()}, {"role": "user", "content": user_message} ] @@ -204,6 +351,11 @@ def generate( result = self._validate_and_process(result) return result + + def _build_system_prompt(self) -> str: + if self.locale == "en": + return ONTOLOGY_SYSTEM_PROMPT_EN + return ONTOLOGY_SYSTEM_PROMPT # 传给 LLM 的文本最大长度(5万字) MAX_TEXT_LENGTH_FOR_LLM = 50000 @@ -223,9 +375,25 @@ def _build_user_message( # 如果文本超过5万字,截断(仅影响传给LLM的内容,不影响图谱构建) if len(combined_text) > self.MAX_TEXT_LENGTH_FOR_LLM: combined_text = combined_text[:self.MAX_TEXT_LENGTH_FOR_LLM] - combined_text += f"\n\n...(原文共{original_length}字,已截取前{self.MAX_TEXT_LENGTH_FOR_LLM}字用于本体分析)..." - - message = f"""## 模拟需求 + if self.locale == "en": + combined_text += ( + f"\n\n...(original text length: {original_length} characters; " + f"only the first {self.MAX_TEXT_LENGTH_FOR_LLM} characters were included for ontology analysis)..." + ) + else: + combined_text += f"\n\n...(原文共{original_length}字,已截取前{self.MAX_TEXT_LENGTH_FOR_LLM}字用于本体分析)..." + + if self.locale == "en": + message = f"""## Simulation Requirement + +{simulation_requirement} + +## Source Documents + +{combined_text} +""" + else: + message = f"""## 模拟需求 {simulation_requirement} @@ -233,15 +401,35 @@ def _build_user_message( {combined_text} """ - + if additional_context: - message += f""" + if self.locale == "en": + message += f""" +## Additional Context + +{additional_context} +""" + else: + message += f""" ## 额外说明 {additional_context} """ - - message += """ + + if self.locale == "en": + message += """ +Please design entity types and relationship types suitable for this social-opinion simulation. + +**Rules you must follow**: +1. Output exactly 10 entity types +2. The last 2 must be the fallback types: Person and Organization +3. The first 8 must be specific types derived from the text +4. Every entity type must be a real actor that can speak or interact online, not an abstract concept +5. Attribute names must not use reserved fields such as name, uuid, or group_id; prefer names like full_name and org_name +6. Keep all descriptions and `analysis_summary` in English +""" + else: + message += """ 请根据以上内容,设计适合社会舆论模拟的实体类型和关系类型。 **必须遵守的规则**: @@ -266,7 +454,17 @@ def _validate_and_process(self, result: Dict[str, Any]) -> Dict[str, Any]: result["analysis_summary"] = "" # 验证实体类型 + validated_entities = [] for entity in result["entity_types"]: + if isinstance(entity, str): + entity = { + "name": entity, + "description": f"Entity type: {entity}" + } + if not isinstance(entity, dict): + continue + if entity.get("name"): + entity["name"] = self._to_pascal_case(entity["name"]) if "attributes" not in entity: entity["attributes"] = [] if "examples" not in entity: @@ -274,15 +472,36 @@ def _validate_and_process(self, result: Dict[str, Any]) -> Dict[str, Any]: # 确保description不超过100字符 if len(entity.get("description", "")) > 100: entity["description"] = entity["description"][:97] + "..." + validated_entities.append(entity) + result["entity_types"] = validated_entities # 验证关系类型 + validated_edges = [] for edge in result["edge_types"]: + if isinstance(edge, str): + edge = { + "name": edge, + "description": f"Relationship type: {edge}" + } + if not isinstance(edge, dict): + continue + if edge.get("name"): + edge["name"] = self._to_screaming_snake_case(edge["name"]) if "source_targets" not in edge: edge["source_targets"] = [] + for source_target in edge["source_targets"]: + if not isinstance(source_target, dict): + continue + if source_target.get("source"): + source_target["source"] = self._to_pascal_case(source_target["source"]) + if source_target.get("target"): + source_target["target"] = self._to_pascal_case(source_target["target"]) if "attributes" not in edge: edge["attributes"] = [] if len(edge.get("description", "")) > 100: edge["description"] = edge["description"][:97] + "..." + validated_edges.append(edge) + result["edge_types"] = validated_edges # Zep API 限制:最多 10 个自定义实体类型,最多 10 个自定义边类型 MAX_ENTITY_TYPES = 10 @@ -343,6 +562,32 @@ def _validate_and_process(self, result: Dict[str, Any]) -> Dict[str, Any]: result["edge_types"] = result["edge_types"][:MAX_EDGE_TYPES] return result + + @staticmethod + def _to_pascal_case(value: Any) -> str: + text = str(value).strip() + if not text: + return "" + + normalized = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", text) + parts = [part for part in re.split(r"[^A-Za-z0-9]+", normalized) if part] + converted = [] + for part in parts: + if part.isupper() or part.islower(): + converted.append(part.lower().capitalize()) + else: + converted.append(part[:1].upper() + part[1:]) + return "".join(converted) + + @staticmethod + def _to_screaming_snake_case(value: Any) -> str: + text = str(value).strip() + if not text: + return "" + + normalized = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", text) + parts = [part.upper() for part in re.split(r"[^A-Za-z0-9]+", normalized) if part] + return "_".join(parts) def generate_python_code(self, ontology: Dict[str, Any]) -> str: """ @@ -450,4 +695,3 @@ def generate_python_code(self, ontology: Dict[str, Any]) -> str: code_lines.append('}') return '\n'.join(code_lines) - diff --git a/backend/app/services/report_agent.py b/backend/app/services/report_agent.py index 02ca5bdc..d52e8edc 100644 --- a/backend/app/services/report_agent.py +++ b/backend/app/services/report_agent.py @@ -19,6 +19,7 @@ from enum import Enum from ..config import Config +from ..i18n import tr from ..utils.llm_client import LLMClient from ..utils.logger import get_logger from .zep_tools import ( @@ -32,6 +33,14 @@ logger = get_logger('mirofish.report_agent') +def _locale_text(locale: str, en_text: str, zh_text: str) -> str: + return en_text if locale == "en" else zh_text + + +def _log_with_locale(level: str, locale: str, en_text: str, zh_text: str, *args: Any) -> None: + getattr(logger, level)(_locale_text(locale, en_text, zh_text), *args) + + class ReportLogger: """ Report Agent 详细日志记录器 @@ -40,7 +49,7 @@ class ReportLogger: 每行是一个完整的 JSON 对象,包含时间戳、动作类型、详细内容等。 """ - def __init__(self, report_id: str): + def __init__(self, report_id: str, locale: str = "zh"): """ 初始化日志记录器 @@ -48,11 +57,15 @@ def __init__(self, report_id: str): report_id: 报告ID,用于确定日志文件路径 """ self.report_id = report_id + self.locale = "en" if locale == "en" else "zh" self.log_file_path = os.path.join( Config.UPLOAD_FOLDER, 'reports', report_id, 'agent_log.jsonl' ) self.start_time = datetime.now() self._ensure_log_file() + + def _tr(self, key: str, **kwargs: Any) -> str: + return tr(key, self.locale, **kwargs) def _ensure_log_file(self): """确保日志文件所在目录存在""" @@ -105,7 +118,7 @@ def log_start(self, simulation_id: str, graph_id: str, simulation_requirement: s "simulation_id": simulation_id, "graph_id": graph_id, "simulation_requirement": simulation_requirement, - "message": "报告生成任务开始" + "message": self._tr("report.log_started"), } ) @@ -114,7 +127,7 @@ def log_planning_start(self): self.log( action="planning_start", stage="planning", - details={"message": "开始规划报告大纲"} + details={"message": self._tr("report.log_planning_started")} ) def log_planning_context(self, context: Dict[str, Any]): @@ -123,7 +136,7 @@ def log_planning_context(self, context: Dict[str, Any]): action="planning_context", stage="planning", details={ - "message": "获取模拟上下文信息", + "message": self._tr("report.log_planning_context_loaded"), "context": context } ) @@ -134,7 +147,7 @@ def log_planning_complete(self, outline_dict: Dict[str, Any]): action="planning_complete", stage="planning", details={ - "message": "大纲规划完成", + "message": self._tr("report.log_planning_completed"), "outline": outline_dict } ) @@ -146,7 +159,12 @@ def log_section_start(self, section_title: str, section_index: int): stage="generating", section_title=section_title, section_index=section_index, - details={"message": f"开始生成章节: {section_title}"} + details={ + "message": self._tr( + "report.log_section_started", + section_title=section_title, + ) + } ) def log_react_thought(self, section_title: str, section_index: int, iteration: int, thought: str): @@ -159,7 +177,7 @@ def log_react_thought(self, section_title: str, section_index: int, iteration: i details={ "iteration": iteration, "thought": thought, - "message": f"ReACT 第{iteration}轮思考" + "message": self._tr("report.log_react_iteration", iteration=iteration), } ) @@ -181,7 +199,7 @@ def log_tool_call( "iteration": iteration, "tool_name": tool_name, "parameters": parameters, - "message": f"调用工具: {tool_name}" + "message": self._tr("report.log_tool_call", tool_name=tool_name), } ) @@ -204,7 +222,7 @@ def log_tool_result( "tool_name": tool_name, "result": result, # 完整结果,不截断 "result_length": len(result), - "message": f"工具 {tool_name} 返回结果" + "message": self._tr("report.log_tool_result", tool_name=tool_name), } ) @@ -229,7 +247,11 @@ def log_llm_response( "response_length": len(response), "has_tool_calls": has_tool_calls, "has_final_answer": has_final_answer, - "message": f"LLM 响应 (工具调用: {has_tool_calls}, 最终答案: {has_final_answer})" + "message": self._tr( + "report.log_llm_response", + has_tool_calls=has_tool_calls, + has_final_answer=has_final_answer, + ), } ) @@ -250,7 +272,10 @@ def log_section_content( "content": content, # 完整内容,不截断 "content_length": len(content), "tool_calls_count": tool_calls_count, - "message": f"章节 {section_title} 内容生成完成" + "message": self._tr( + "report.log_section_content_completed", + section_title=section_title, + ), } ) @@ -273,7 +298,10 @@ def log_section_full_complete( details={ "content": full_content, "content_length": len(full_content), - "message": f"章节 {section_title} 生成完成" + "message": self._tr( + "report.log_section_completed", + section_title=section_title, + ), } ) @@ -285,7 +313,7 @@ def log_report_complete(self, total_sections: int, total_time_seconds: float): details={ "total_sections": total_sections, "total_time_seconds": round(total_time_seconds, 2), - "message": "报告生成完成" + "message": self._tr("report.log_completed"), } ) @@ -298,7 +326,7 @@ def log_error(self, error_message: str, stage: str, section_title: str = None): section_index=None, details={ "error": error_message, - "message": f"发生错误: {error_message}" + "message": self._tr("report.log_error", error=error_message), } ) @@ -472,7 +500,27 @@ def to_dict(self) -> Dict[str, Any]: # ── 工具描述 ── -TOOL_DESC_INSIGHT_FORGE = """\ +TOOL_SPECS = { + "insight_forge": { + "description": { + "en": """\ +[Deep insight retrieval - powerful analysis tool] +Our strongest retrieval tool for deep analysis. It: +1. Breaks your question into focused sub-questions +2. Searches the simulation graph from multiple angles +3. Combines semantic search, entity analysis, and relationship tracing +4. Returns the most comprehensive and detailed supporting material + +[Best for] +- Deep analysis of a topic +- Understanding multiple facets of an event +- Gathering rich evidence for a report section + +[Returns] +- Relevant factual excerpts that can be quoted directly +- Core entity insights +- Relationship-chain analysis""", + "zh": """\ 【深度洞察检索 - 强大的检索工具】 这是我们强大的检索函数,专为深度分析设计。它会: 1. 自动将你的问题分解为多个子问题 @@ -488,9 +536,38 @@ def to_dict(self) -> Dict[str, Any]: 【返回内容】 - 相关事实原文(可直接引用) - 核心实体洞察 -- 关系链分析""" - -TOOL_DESC_PANORAMA_SEARCH = """\ +- 关系链分析""", + }, + "parameters": { + "query": { + "en": "Question or topic to analyze deeply", + "zh": "你想深入分析的问题或话题", + }, + "report_context": { + "en": "Current report-section context (optional, helps generate better sub-questions)", + "zh": "当前报告章节的上下文(可选,有助于生成更精准的子问题)", + }, + }, + }, + "panorama_search": { + "description": { + "en": """\ +[Broad search - full-picture view] +Use this tool to get a wide overview of simulation results, especially to understand how an event evolved. It: +1. Retrieves all relevant nodes and relationships +2. Separates currently valid facts from historical or expired ones +3. Helps you understand how sentiment and discussion evolved + +[Best for] +- Understanding the full timeline of an event +- Comparing sentiment changes across stages +- Gathering comprehensive entity and relationship information + +[Returns] +- Currently valid facts (latest simulation state) +- Historical or expired facts (evolution record) +- All involved entities""", + "zh": """\ 【广度搜索 - 获取全貌视图】 这个工具用于获取模拟结果的完整全貌,特别适合了解事件演变过程。它会: 1. 获取所有相关节点和关系 @@ -505,9 +582,33 @@ def to_dict(self) -> Dict[str, Any]: 【返回内容】 - 当前有效事实(模拟最新结果) - 历史/过期事实(演变记录) -- 所有涉及的实体""" - -TOOL_DESC_QUICK_SEARCH = """\ +- 所有涉及的实体""", + }, + "parameters": { + "query": { + "en": "Search query used for relevance ranking", + "zh": "搜索查询,用于相关性排序", + }, + "include_expired": { + "en": "Whether to include expired or historical facts (default: True)", + "zh": "是否包含过期/历史内容(默认True)", + }, + }, + }, + "quick_search": { + "description": { + "en": """\ +[Quick search - fast retrieval] +A lightweight retrieval tool for simple, direct information lookup. + +[Best for] +- Quickly finding a specific piece of information +- Verifying a fact +- Simple information retrieval + +[Returns] +- A list of facts most relevant to the query""", + "zh": """\ 【简单搜索 - 快速检索】 轻量级的快速检索工具,适合简单、直接的信息查询。 @@ -517,9 +618,49 @@ def to_dict(self) -> Dict[str, Any]: - 简单的信息检索 【返回内容】 -- 与查询最相关的事实列表""" - -TOOL_DESC_INTERVIEW_AGENTS = """\ +- 与查询最相关的事实列表""", + }, + "parameters": { + "query": { + "en": "Search query string", + "zh": "搜索查询字符串", + }, + "limit": { + "en": "Number of results to return (optional, default: 10)", + "zh": "返回结果数量(可选,默认10)", + }, + }, + }, + "interview_agents": { + "description": { + "en": """\ +[Deep interviews - real agent interviews across both platforms] +Call the OASIS interview API to interview live simulation agents directly. +This is not an LLM reenactment. It uses the real interview endpoints to fetch raw agent responses. +By default it interviews across both Twitter and Reddit to gather broader viewpoints. + +Workflow: +1. Read the generated persona files to understand the available agents +2. Select the agents most relevant to the interview topic +3. Generate interview questions automatically +4. Call /api/simulation/interview/batch to run real interviews on both platforms +5. Consolidate the results into a multi-perspective analysis + +[Best for] +- Understanding how different roles view an event +- Collecting positions and opinions from multiple sides +- Quoting raw answers from live simulation agents +- Making the report more vivid with interview excerpts + +[Returns] +- Interviewed-agent identity details +- Answers from Twitter and Reddit interviews +- Key quotes that can be cited directly +- Interview summaries and viewpoint comparisons + +[Important] +The OASIS simulation environment must be running to use this tool.""", + "zh": """\ 【深度采访 - 真实Agent采访(双平台)】 调用OASIS模拟环境的采访API,对正在运行的模拟Agent进行真实采访! 这不是LLM模拟,而是调用真实的采访接口获取模拟Agent的原始回答。 @@ -544,7 +685,20 @@ def to_dict(self) -> Dict[str, Any]: - 关键引言(可直接引用) - 采访摘要和观点对比 -【重要】需要OASIS模拟环境正在运行才能使用此功能!""" +【重要】需要OASIS模拟环境正在运行才能使用此功能!""", + }, + "parameters": { + "interview_topic": { + "en": "Interview topic or request (for example: 'Understand student reactions to the dorm formaldehyde incident')", + "zh": "采访主题或需求描述(如:'了解学生对宿舍甲醛事件的看法')", + }, + "max_agents": { + "en": "Maximum number of agents to interview (optional, default: 5, max: 10)", + "zh": "最多采访的Agent数量(可选,默认5,最大10)", + }, + }, + }, +} # ── 大纲规划 prompt ── @@ -567,6 +721,14 @@ def to_dict(self) -> Dict[str, Any]: - ❌ 不是对现实世界现状的分析 - ❌ 不是泛泛而谈的舆情综述 +【标题与摘要要求】 +- 标题和摘要必须直接呼应用户的模拟需求,优先复用需求中的核心对象、产品、事件或人群词汇 +- 标题要简洁、直白、可读,让用户一眼看出这份报告在回答什么问题 +- 优先使用“XX分析与预测”“XX受众预测”“XX趋势研判”这类清晰表达 +- 避免空泛、文学化、过度学术化或故作深沉的标题 +- 禁止使用与用户问题脱节的抽象比喻或夸张措辞,例如“静默”“解体”“史诗”“挽歌”“终局”等 +- 如果用户的问题很具体,标题就必须具体,不要擅自扩大到宏大叙事 + 【章节数量限制】 - 最少2个章节,最多5个章节 - 不需要子章节,每个章节直接撰写完整内容 @@ -587,6 +749,53 @@ def to_dict(self) -> Dict[str, Any]: 注意:sections数组最少2个,最多5个元素!""" +PLAN_SYSTEM_PROMPT_EN = """\ +You are an expert writer of "future forecast reports" with a bird's-eye view of the simulated world. You can inspect each agent's behavior, statements, and interactions. + +[Core framing] +We built a simulated world and injected a specific "simulation requirement" as the driving condition. The evolution of that world is a forecast of what could happen in the future. You are not looking at ordinary experimental data. You are observing a rehearsal of the future. + +[Your task] +Write a "future forecast report" that answers: +1. What happened in the simulated future under the given condition? +2. How did different agents and groups react and act? +3. What future trends and risks does this simulation reveal? + +[Report positioning] +- This is a simulation-based forecast report about what may happen next. +- Focus on predicted outcomes: event direction, audience reactions, emergent behavior, and potential risks. +- Agent behavior in the simulation is evidence about likely future human behavior. +- Do not turn this into a report about the current real-world situation. +- Do not write a generic public-opinion summary. + +[Title and summary requirements] +- The title and summary must directly reflect the user's simulation requirement and should reuse the core object, product, event, or audience terms from that requirement when possible. +- The title must be concise, concrete, and easy to understand at a glance. +- Prefer clear phrasing such as "XX analysis and forecast", "XX audience forecast", or "XX trend outlook". +- Avoid vague, literary, overly academic, or theatrically dramatic wording. +- Do not use abstract metaphors or exaggerated phrasing unrelated to the user's question, such as "silence", "collapse", "epic", "elegy", or "endgame". +- If the user's question is specific, the title must stay specific instead of drifting into grand narratives. + +[Section count limits] +- Use at least 2 sections and at most 5 sections. +- Do not create subsections. +- Keep each section focused on the most important forecast findings. +- Design the section structure based on the forecast itself. + +Return the report outline as JSON in this format: +{ + "title": "Report title", + "summary": "One-sentence summary of the core forecast finding", + "sections": [ + { + "title": "Section title", + "description": "Section description" + } + ] +} + +Important: the sections array must contain at least 2 and at most 5 items.""" + PLAN_USER_PROMPT_TEMPLATE = """\ 【预测场景设定】 我们向模拟世界注入的变量(模拟需求):{simulation_requirement} @@ -607,8 +816,41 @@ def to_dict(self) -> Dict[str, Any]: 根据预测结果,设计最合适的报告章节结构。 +补充要求: +1. 报告标题必须让普通用户直接看懂,并且要能从标题看出它回应的是哪一个模拟需求。 +2. 报告摘要必须用一句人话概括核心发现,不要写成空泛口号。 +3. 如果模拟需求是在预测某个产品、方案、游戏、事件或人群,就在标题里明确点出该对象。 + 【再次提醒】报告章节数量:最少2个,最多5个,内容要精炼聚焦于核心预测发现。""" +PLAN_USER_PROMPT_TEMPLATE_EN = """\ +[Forecast scenario] +Injected variable (simulation requirement): {simulation_requirement} + +[Simulation scale] +- Number of entities involved: {total_nodes} +- Number of relationships created: {total_edges} +- Entity type distribution: {entity_types} +- Number of active agents: {total_entities} + +[Sample future facts observed in the simulation] +{related_facts_json} + +Review this simulated future from a bird's-eye view: +1. What kind of future state emerged under the given condition? +2. How did different audiences and agents react and act? +3. What future trends are worth paying attention to? + +Design the most suitable report section structure based on the forecast. + +Additional requirements: +1. The report title must be immediately understandable to a general reader, and it must make clear which simulation requirement it answers. +2. The report summary must explain the core finding in one plain-language sentence instead of a slogan. +3. If the simulation predicts a product, plan, game, event, or audience, name that object directly in the title. + +[Reminder] +Use at least 2 sections and at most 5 sections. Keep the content concise and focused on the core forecast findings.""" + # ── 章节生成 prompt ── SECTION_SYSTEM_PROMPT_TEMPLATE = """\ @@ -651,12 +893,7 @@ def to_dict(self) -> Dict[str, Any]: > "某类人群会表示:原文内容..." - 这些引用是模拟预测的核心证据 -3. 【语言一致性 - 引用内容必须翻译为报告语言】 - - 工具返回的内容可能包含英文或中英文混杂的表述 - - 如果模拟需求和材料原文是中文的,报告必须全部使用中文撰写 - - 当你引用工具返回的英文或中英混杂内容时,必须将其翻译为流畅的中文后再写入报告 - - 翻译时保持原意不变,确保表述自然通顺 - - 这一规则同时适用于正文和引用块(> 格式)中的内容 +{language_instruction} 4. 【忠实呈现预测结果】 - 报告内容必须反映模拟世界中的代表未来的模拟结果 @@ -765,6 +1002,158 @@ def to_dict(self) -> Dict[str, Any]: 6. 【避免重复】仔细阅读下方已完成的章节内容,不要重复描述相同的信息 7. 【再次强调】不要添加任何标题!用**粗体**代替小节标题""" +SECTION_SYSTEM_PROMPT_TEMPLATE_EN = """\ +You are an expert writer preparing one section of a future forecast report. + +Report title: {report_title} +Report summary: {report_summary} +Forecast scenario (simulation requirement): {simulation_requirement} + +Current section to write: {section_title} + +============================================================ +[Core idea] +============================================================ + +The simulated world is a rehearsal of the future. We injected a specific condition +into the simulation, and the agents' behavior and interactions represent a forecast +of how people may respond in that future. + +Your task is to: +- Reveal what happened in the simulated future under the given condition +- Explain how different audiences and agents reacted and acted +- Identify future trends, risks, and opportunities worth monitoring + +Do not write this as an analysis of the present-day real world. +Focus on "what is likely to happen next" in the simulated future. + +============================================================ +[Most important rules] +============================================================ + +1. [You must use tools to observe the simulated world] + - You are observing the future from a bird's-eye view. + - Every claim must come from events and agent behavior in the simulation. + - Do not use outside knowledge to write the report. + - Each section must call tools at least 3 times and at most 5 times. + +2. [You must quote original agent behavior or speech] + - Agent actions and statements are the core evidence for the future forecast. + - Use block quotes to show the evidence, for example: + > "A representative audience segment might say: original quoted content..." + - These quotes are the main proof for your conclusions. + +{language_instruction} + +4. [Present the forecast faithfully] + - The section must reflect what actually happened in the simulated future. + - Do not add facts that are not present in the simulation. + - If the available information is limited, say so plainly. + +============================================================ +[Formatting rules - critical] +============================================================ + +[One section = one minimum content unit] +- A section is already the smallest report unit. +- Do not use any Markdown headings inside the section (#, ##, ###, ####, etc.). +- Do not repeat the section title at the beginning. +- The system will add the section title automatically. +- Use **bold text**, paragraphs, block quotes, and lists to organize content instead of headings. + +[Correct example] +``` +This section analyzes how public attention spread through the simulated event. +Based on the retrieved simulation evidence, we found that... + +**Initial trigger phase** + +The earliest wave of attention came from highly reactive accounts: + +> "A small group of early posters created the first spike in attention..." + +**Amplification phase** + +Video-first channels then widened the emotional impact: + +- High visual intensity +- Strong emotional resonance +``` + +[Incorrect example] +``` +## Executive summary <- Wrong. Do not add any heading. +### Phase one <- Wrong. Do not use subsection headings. +#### 1.1 Details <- Wrong. Do not nest headings. + +This section analyzes... +``` + +============================================================ +[Available retrieval tools] (use 3-5 times per section) +============================================================ + +{tools_description} + +[Tool usage guidance - mix tools instead of relying on just one] +- insight_forge: deep analysis that decomposes the question and retrieves facts and relationships from multiple angles +- panorama_search: wide-angle search for the overall situation, timeline, and evolution +- quick_search: fast verification of a specific point +- interview_agents: interview simulated agents to capture first-person reactions from different roles + +============================================================ +[Workflow] +============================================================ + +Each reply may do exactly one of the following: + +Option A - Call a tool: +Write your thought, then call one tool in this format: +<tool_call> +{{"name": "tool_name", "parameters": {{"param_name": "value"}}}} +</tool_call> +The system will execute the tool and return the result. Do not invent tool output yourself. + +Option B - Output the final content: +When you have enough information, start the section content with "Final Answer:". + +Strictly forbidden: +- Do not include both a tool call and Final Answer in the same reply. +- Do not fabricate tool observations; all tool results are injected by the system. +- Call at most one tool per reply. + +============================================================ +[Section content requirements] +============================================================ + +1. The content must be based on retrieved simulation evidence. +2. Quote original evidence extensively to show how the simulation evolved. +3. Use Markdown formatting, but do not use headings: + - Use **bold text** for emphasis instead of subheadings + - Use lists (- or 1. 2. 3.) to organize points + - Separate paragraphs with blank lines + - Do not use any heading syntax such as #, ##, ###, #### +4. [Quote formatting rule - quotes must stand alone] + Quotes must be separate paragraphs with a blank line before and after them: + + Correct: + ``` + The official response was widely seen as lacking substance. + + > "The response pattern looked rigid and slow in a fast-moving social media environment." + + This reaction reflects a broader loss of confidence. + ``` + + Incorrect: + ``` + The official response was seen as weak. > "The response pattern..." This reaction reflects... + ``` +5. Keep the logic coherent with the other sections. +6. Read the completed sections carefully and avoid repeating the same information. +7. Do not add any headings. Use **bold text** instead of subsection titles. +""" + SECTION_USER_PROMPT_TEMPLATE = """\ 已完成的章节内容(请仔细阅读,避免重复): {previous_content} @@ -790,6 +1179,32 @@ def to_dict(self) -> Dict[str, Any]: 2. 然后调用工具(Action)获取模拟数据 3. 收集足够信息后输出 Final Answer(纯正文,无任何标题)""" +SECTION_USER_PROMPT_TEMPLATE_EN = """\ +Completed section content (read carefully and avoid repetition): +{previous_content} + +============================================================ +[Current task] Write section: {section_title} +============================================================ + +[Important reminders] +1. Read the completed sections above and avoid repeating the same points. +2. Before writing, you must call tools to gather simulation evidence. +3. Mix different tools instead of relying on just one. +4. The report content must come from retrieved evidence, not your own outside knowledge. + +[Formatting warning - must follow] +- Do not write any headings (#, ##, ###, ####). +- Do not start with "{section_title}". +- The section title will be added automatically by the system. +- Write body content directly, and use **bold text** instead of subsection headings. + +Please begin: +1. First think about what information this section needs. +2. Then call a tool to retrieve simulation evidence. +3. Once you have enough evidence, output Final Answer (body text only, with no headings). +""" + # ── ReACT 循环内消息模板 ── REACT_OBSERVATION_TEMPLATE = """\ @@ -851,10 +1266,37 @@ def to_dict(self) -> Dict[str, Any]: 【回答风格】 - 简洁直接,不要长篇大论 - 使用 > 格式引用关键内容 -- 优先给出结论,再解释原因""" +- 优先给出结论,再解释原因 +- 最终回答必须使用 {report_language}""" + +CHAT_SYSTEM_PROMPT_TEMPLATE_EN = """\ +You are a concise and efficient simulation-forecast assistant. + +[Background] +Forecast condition: {simulation_requirement} + +[Generated analysis report] +{report_content} -CHAT_OBSERVATION_SUFFIX = "\n\n请简洁回答问题。" +[Rules] +1. Prefer answering from the report content above. +2. Answer directly and avoid long internal monologues. +3. Call tools only when the report does not contain enough information. +4. Keep the answer concise, clear, and well structured. +[Available tools] (use only when needed, at most 1-2 calls) +{tools_description} + +[Tool call format] +<tool_call> +{{"name": "tool_name", "parameters": {{"parameter_name": "parameter_value"}}}} +</tool_call> + +[Answer style] +- Be concise and direct. +- Use > block quotes for key supporting content when helpful. +- Lead with the conclusion, then explain why. +- The final answer must be written in {report_language}""" # ═══════════════════════════════════════════════════════════════ # ReportAgent 主类 @@ -879,12 +1321,44 @@ class ReportAgent: # 对话中的最大工具调用次数 MAX_TOOL_CALLS_PER_CHAT = 2 + + ABSTRACT_TITLE_MARKERS_ZH = ( + "静默", + "解体", + "寓言", + "诗学", + "镜像", + "图景", + "谱系", + "变奏", + "宿命", + "迷宫", + "生态", + "叙事", + ) + ABSTRACT_TITLE_MARKERS_EN = ( + "silence", + "collapse", + "ecology", + "poetics", + "allegory", + "deconstruction", + "narrative", + ) + + @staticmethod + def _prune_messages_for_retry(messages: List[Dict[str, str]]) -> List[Dict[str, str]]: + """Keep the initial prompt and latest turns to avoid unbounded context growth.""" + if len(messages) <= 14: + return messages + return messages[:2] + messages[-12:] def __init__( self, graph_id: str, simulation_id: str, simulation_requirement: str, + locale: str = "zh", llm_client: Optional[LLMClient] = None, zep_tools: Optional[ZepToolsService] = None ): @@ -901,6 +1375,7 @@ def __init__( self.graph_id = graph_id self.simulation_id = simulation_id self.simulation_requirement = simulation_requirement + self.locale = "en" if locale == "en" else "zh" self.llm = llm_client or LLMClient() self.zep_tools = zep_tools or ZepToolsService() @@ -913,43 +1388,334 @@ def __init__( # 控制台日志记录器(在 generate_report 中初始化) self.console_logger: Optional[ReportConsoleLogger] = None - logger.info(f"ReportAgent 初始化完成: graph_id={graph_id}, simulation_id={simulation_id}") + self._log( + "info", + "ReportAgent initialized: graph_id=%s, simulation_id=%s", + "ReportAgent 初始化完成: graph_id=%s, simulation_id=%s", + graph_id, + simulation_id, + ) + + def _text(self, en_text: str, zh_text: str) -> str: + return en_text if self.locale == "en" else zh_text + + def _log(self, level: str, en_text: str, zh_text: str, *args: Any) -> None: + _log_with_locale(level, self.locale, en_text, zh_text, *args) + + def _report_language_name(self) -> str: + return "English" if self.locale == "en" else "中文" + + def _report_language_prompt_block(self) -> str: + if self.locale == "en": + return """3. 【Language consistency】 + - Write the entire report section in natural English. + - If tool output contains Chinese or mixed-language text, translate it into fluent English before quoting or summarizing it. + - Preserve the original meaning while keeping the wording clear and readable. + - Apply this rule to both body paragraphs and block quotes. +""" + + return """3. 【语言一致性 - 引用内容必须翻译为报告语言】 + - 工具返回的内容可能包含英文或中英文混杂的表述 + - 如果模拟需求和材料原文是中文的,报告必须全部使用中文撰写 + - 当你引用工具返回的英文或中英混杂内容时,必须将其翻译为流畅的中文后再写入报告 + - 翻译时保持原意不变,确保表述自然通顺 + - 这一规则同时适用于正文和引用块(> 格式)中的内容 +""" + + def _section_generation_progress_message(self, tool_calls_count: int) -> str: + return self._text( + f"Deep retrieval and drafting in progress ({tool_calls_count}/{self.MAX_TOOL_CALLS_PER_SECTION})", + f"深度检索与撰写中 ({tool_calls_count}/{self.MAX_TOOL_CALLS_PER_SECTION})", + ) + + def _first_section_placeholder(self) -> str: + return self._text("(This is the first section)", "(这是第一个章节)") + + def _report_context_text(self, section_title: str) -> str: + return self._text( + f"Section title: {section_title}\nSimulation requirement: {self.simulation_requirement}", + f"章节标题: {section_title}\n模拟需求: {self.simulation_requirement}", + ) + + def _build_section_system_prompt(self, outline: "ReportOutline", section: "ReportSection") -> str: + template = ( + SECTION_SYSTEM_PROMPT_TEMPLATE_EN + if self.locale == "en" + else SECTION_SYSTEM_PROMPT_TEMPLATE + ) + return template.format( + report_title=outline.title, + report_summary=outline.summary, + simulation_requirement=self.simulation_requirement, + section_title=section.title, + tools_description=self._get_tools_description(), + language_instruction=self._report_language_prompt_block().rstrip(), + ) + + def _build_section_user_prompt(self, *, previous_content: str, section_title: str) -> str: + template = ( + SECTION_USER_PROMPT_TEMPLATE_EN + if self.locale == "en" + else SECTION_USER_PROMPT_TEMPLATE + ) + return template.format( + previous_content=previous_content, + section_title=section_title, + ) + + def _empty_response_retry_messages(self) -> tuple[str, str]: + return ( + self._text("(The response was empty)", "(响应为空)"), + self._text("Please continue generating the content.", "请继续生成内容。"), + ) + + def _chat_report_placeholder(self) -> str: + return self._text("(No report available yet)", "(暂无报告)") + + def _chat_report_truncated_marker(self) -> str: + return self._text( + "\n\n... [Report content truncated] ...", + "\n\n... [报告内容已截断] ...", + ) + + def _chat_tool_observation(self, tool_name: str, result: str) -> str: + return self._text( + f"[Tool {tool_name} result]\n{result}", + f"[{tool_name}结果]\n{result}", + ) + + def _chat_observation_suffix(self) -> str: + return self._text( + "\n\nPlease answer the question concisely.", + "\n\n请简洁回答问题。", + ) + + def _chat_empty_response_text(self) -> str: + return self._text( + "(The assistant returned an empty response. Please try again.)", + "(助手返回了空响应,请重试。)", + ) + + def _build_chat_system_prompt(self, *, report_content: str) -> str: + template = ( + CHAT_SYSTEM_PROMPT_TEMPLATE_EN + if self.locale == "en" + else CHAT_SYSTEM_PROMPT_TEMPLATE + ) + return template.format( + simulation_requirement=self.simulation_requirement, + report_content=report_content if report_content else self._chat_report_placeholder(), + tools_description=self._get_tools_description(), + report_language=self._report_language_name(), + ) + + def _react_conflict_retry_message(self) -> str: + return self._text( + "Format error: your reply included both a tool call and Final Answer, which is not allowed.\n" + "Each reply must do exactly one of the following:\n" + '- Call one tool (output a single <tool_call> block and do not write Final Answer)\n' + "- Output the final content (start with 'Final Answer:' and do not include <tool_call>)\n" + "Please reply again and do only one of them.", + "【格式错误】你在一次回复中同时包含了工具调用和 Final Answer,这是不允许的。\n" + "每次回复只能做以下两件事之一:\n" + "- 调用一个工具(输出一个 <tool_call> 块,不要写 Final Answer)\n" + "- 输出最终内容(以 'Final Answer:' 开头,不要包含 <tool_call>)\n" + "请重新回复,只做其中一件事。", + ) + + def _react_unused_tools_hint(self, unused_tools: set[str]) -> str: + if not unused_tools: + return "" + unused_list = ", ".join(sorted(unused_tools)) + return self._text( + f"\nTip: you have not used these tools yet: {unused_list}. Try mixing tools for broader evidence.", + f"\n💡 你还没有使用过: {'、'.join(sorted(unused_tools))},建议尝试不同工具获取多角度信息", + ) + + def _react_observation_message( + self, + *, + tool_name: str, + result: str, + tool_calls_count: int, + max_tool_calls: int, + used_tools: set[str], + unused_hint: str, + ) -> str: + if self.locale == "en": + return ( + "Observation:\n\n" + f"=== Tool {tool_name} returned ===\n" + f"{result}\n\n" + "============================================================\n" + f"Tool calls used: {tool_calls_count}/{max_tool_calls} (used: {', '.join(sorted(used_tools))}){unused_hint}\n" + '- If you have enough information: output the section content starting with "Final Answer:" and cite the quoted evidence above.\n' + "- If you need more information: call one more tool.\n" + "============================================================" + ) + return REACT_OBSERVATION_TEMPLATE.format( + tool_name=tool_name, + result=result, + tool_calls_count=tool_calls_count, + max_tool_calls=max_tool_calls, + used_tools_str=", ".join(sorted(used_tools)), + unused_hint=unused_hint, + ) + + def _react_insufficient_tools_message( + self, + *, + tool_calls_count: int, + min_tool_calls: int, + unused_hint: str, + alternate: bool = False, + ) -> str: + if self.locale == "en": + if alternate: + return ( + f"You have only used {tool_calls_count} tool calls so far; at least {min_tool_calls} are required. " + f"Please call a tool to gather simulation data.{unused_hint}" + ) + return ( + f"Notice: you have only used {tool_calls_count} tool calls; at least {min_tool_calls} are required. " + f"Please call another tool to gather more simulation evidence before outputting Final Answer.{unused_hint}" + ) + template = REACT_INSUFFICIENT_TOOLS_MSG_ALT if alternate else REACT_INSUFFICIENT_TOOLS_MSG + return template.format( + tool_calls_count=tool_calls_count, + min_tool_calls=min_tool_calls, + unused_hint=unused_hint, + ) + + def _react_tool_limit_message(self, *, tool_calls_count: int, max_tool_calls: int) -> str: + return self._text( + f"The tool-call limit has been reached ({tool_calls_count}/{max_tool_calls}); you cannot call more tools. " + 'Please immediately output the section content starting with "Final Answer:" based on the information already collected.', + REACT_TOOL_LIMIT_MSG.format( + tool_calls_count=tool_calls_count, + max_tool_calls=max_tool_calls, + ), + ) + + def _react_force_final_message(self) -> str: + return self._text( + 'The tool-call limit has been reached. Please output "Final Answer:" and write the section content directly.', + REACT_FORCE_FINAL_MSG, + ) + + def _empty_response_fallback_text(self) -> str: + return self._text( + "(This section could not be generated because the LLM returned an empty response. Please try again later.)", + "(本章节生成失败:LLM 返回空响应,请稍后重试)", + ) + + def _requirement_subject_for_title(self) -> str: + requirement = (self.simulation_requirement or "").strip() + if not requirement: + return "" + + if self.locale == "en": + subject = requirement + subject = re.sub(r"^[Pp]lease\s+", "", subject) + subject = re.sub( + r"^(predict|analyze|analyse|assess|evaluate|research|explore)\s+", + "", + subject, + ) + subject = re.sub( + r"^(the|this|these|those)\s+", + "", + subject, + ) + subject = re.sub( + r"\b(will|would|could|should|might|can)\b.*$", + "", + subject, + ) + subject = re.sub(r"[?.!]+$", "", subject).strip(" -,:;") + return subject[:60].strip() + + subject = re.sub(r"\s+", "", requirement) + subject = re.sub( + r"^(请|请问|帮我|麻烦你|想请你)?(分析|预测|评估|判断|研究|推测|模拟|看看|看下)+", + "", + subject, + ) + subject = re.sub(r"^(这个|该|此)", "", subject) + subject = re.sub(r"(会是什么样|是什么样|会如何|如何|怎么样|吗|呢|呀|啊|吧)+$", "", subject) + subject = re.sub(r"[??!!。;;,,]+$", "", subject) + subject = subject.replace("的", "") + return subject[:24] + + def _fallback_outline_title(self) -> str: + subject = self._requirement_subject_for_title() + if not subject: + return "Forecast Analysis" if self.locale == "en" else "模拟分析报告" + if self.locale == "en": + return f"{subject} Analysis" + return f"{subject}分析报告" + + def _normalize_outline_title(self, title: str) -> str: + normalized = re.sub(r"\s+", " ", (title or "")).strip().strip("\"'“”‘’") + normalized = normalized.strip("《》") + normalized = re.sub( + r"[::]\s*(一项基于模拟的预测报告|基于模拟的预测报告|模拟分析报告|预测报告)$", + "", + normalized, + ).strip() + normalized = re.sub( + r"[::]\s*(a simulation[- ]based forecast report|forecast report|analysis report)$", + "", + normalized, + flags=re.IGNORECASE, + ).strip() + + fallback_title = self._fallback_outline_title() + requirement_subject = self._requirement_subject_for_title() + lowered_title = normalized.lower() + lowered_subject = requirement_subject.lower() + has_subject_overlap = bool( + requirement_subject and ( + requirement_subject in normalized + or lowered_subject in lowered_title + ) + ) + + if not normalized: + return fallback_title + + if self.locale == "en": + has_abstract_marker = any(marker in lowered_title for marker in self.ABSTRACT_TITLE_MARKERS_EN) + is_too_generic = normalized in {"Forecast Analysis", "Analysis Report", "Future Forecast Report"} + else: + has_abstract_marker = any(marker in normalized for marker in self.ABSTRACT_TITLE_MARKERS_ZH) + is_too_generic = normalized in {"未来预测报告", "模拟分析报告", "预测报告"} + + if requirement_subject and not has_subject_overlap and (has_abstract_marker or is_too_generic): + self._log( + "info", + "Outline title is too abstract; falling back to requirement-anchored title: %s -> %s", + "报告标题过于抽象,回退到需求导向标题: %s -> %s", + normalized, + fallback_title, + ) + return fallback_title + + return normalized def _define_tools(self) -> Dict[str, Dict[str, Any]]: """定义可用工具""" + locale = "en" if self.locale == "en" else "zh" return { - "insight_forge": { - "name": "insight_forge", - "description": TOOL_DESC_INSIGHT_FORGE, - "parameters": { - "query": "你想深入分析的问题或话题", - "report_context": "当前报告章节的上下文(可选,有助于生成更精准的子问题)" - } - }, - "panorama_search": { - "name": "panorama_search", - "description": TOOL_DESC_PANORAMA_SEARCH, - "parameters": { - "query": "搜索查询,用于相关性排序", - "include_expired": "是否包含过期/历史内容(默认True)" - } - }, - "quick_search": { - "name": "quick_search", - "description": TOOL_DESC_QUICK_SEARCH, - "parameters": { - "query": "搜索查询字符串", - "limit": "返回结果数量(可选,默认10)" - } - }, - "interview_agents": { - "name": "interview_agents", - "description": TOOL_DESC_INTERVIEW_AGENTS, + name: { + "name": name, + "description": spec["description"][locale], "parameters": { - "interview_topic": "采访主题或需求描述(如:'了解学生对宿舍甲醛事件的看法')", - "max_agents": "最多采访的Agent数量(可选,默认5,最大10)" - } + param_name: param_spec[locale] + for param_name, param_spec in spec["parameters"].items() + }, } + for name, spec in TOOL_SPECS.items() } def _execute_tool(self, tool_name: str, parameters: Dict[str, Any], report_context: str = "") -> str: @@ -964,7 +1730,13 @@ def _execute_tool(self, tool_name: str, parameters: Dict[str, Any], report_conte Returns: 工具执行结果(文本格式) """ - logger.info(f"执行工具: {tool_name}, 参数: {parameters}") + self._log( + "info", + "Executing tool: %s, parameters: %s", + "执行工具: %s, 参数: %s", + tool_name, + parameters, + ) try: if tool_name == "insight_forge": @@ -1023,7 +1795,11 @@ def _execute_tool(self, tool_name: str, parameters: Dict[str, Any], report_conte elif tool_name == "search_graph": # 重定向到 quick_search - logger.info("search_graph 已重定向到 quick_search") + self._log( + "info", + "search_graph redirected to quick_search", + "search_graph 已重定向到 quick_search", + ) return self._execute_tool("quick_search", parameters, report_context) elif tool_name == "get_graph_statistics": @@ -1040,7 +1816,11 @@ def _execute_tool(self, tool_name: str, parameters: Dict[str, Any], report_conte elif tool_name == "get_simulation_context": # 重定向到 insight_forge,因为它更强大 - logger.info("get_simulation_context 已重定向到 insight_forge") + self._log( + "info", + "get_simulation_context redirected to insight_forge", + "get_simulation_context 已重定向到 insight_forge", + ) query = parameters.get("query", self.simulation_requirement) return self._execute_tool("insight_forge", {"query": query}, report_context) @@ -1054,11 +1834,23 @@ def _execute_tool(self, tool_name: str, parameters: Dict[str, Any], report_conte return json.dumps(result, ensure_ascii=False, indent=2) else: - return f"未知工具: {tool_name}。请使用以下工具之一: insight_forge, panorama_search, quick_search" + return self._text( + f"Unknown tool: {tool_name}. Use one of: insight_forge, panorama_search, quick_search", + f"未知工具: {tool_name}。请使用以下工具之一: insight_forge, panorama_search, quick_search", + ) except Exception as e: - logger.error(f"工具执行失败: {tool_name}, 错误: {str(e)}") - return f"工具执行失败: {str(e)}" + self._log( + "error", + "Tool execution failed: %s, error: %s", + "工具执行失败: %s, 错误: %s", + tool_name, + str(e), + ) + return self._text( + f"Tool execution failed: {str(e)}", + f"工具执行失败: {str(e)}", + ) # 合法的工具名称集合,用于裸 JSON 兜底解析时校验 VALID_TOOL_NAMES = {"insight_forge", "panorama_search", "quick_search", "interview_agents"} @@ -1125,12 +1917,12 @@ def _is_valid_tool_call(self, data: dict) -> bool: def _get_tools_description(self) -> str: """生成工具描述文本""" - desc_parts = ["可用工具:"] + desc_parts = [self._text("Available tools:", "可用工具:")] for name, tool in self.tools.items(): params_desc = ", ".join([f"{k}: {v}" for k, v in tool["parameters"].items()]) desc_parts.append(f"- {name}: {tool['description']}") if params_desc: - desc_parts.append(f" 参数: {params_desc}") + desc_parts.append(f" {self._text('Parameters', '参数')}: {params_desc}") return "\n".join(desc_parts) def plan_outline( @@ -1148,10 +1940,10 @@ def plan_outline( Returns: ReportOutline: 报告大纲 """ - logger.info("开始规划报告大纲...") + self._log("info", "Starting report outline planning...", "开始规划报告大纲...") if progress_callback: - progress_callback("planning", 0, "正在分析模拟需求...") + progress_callback("planning", 0, self._text("Analyzing the simulation requirement...", "正在分析模拟需求...")) # 首先获取模拟上下文 context = self.zep_tools.get_simulation_context( @@ -1160,10 +1952,11 @@ def plan_outline( ) if progress_callback: - progress_callback("planning", 30, "正在生成报告大纲...") + progress_callback("planning", 30, self._text("Generating the report outline...", "正在生成报告大纲...")) - system_prompt = PLAN_SYSTEM_PROMPT - user_prompt = PLAN_USER_PROMPT_TEMPLATE.format( + system_prompt = self._text(PLAN_SYSTEM_PROMPT_EN, PLAN_SYSTEM_PROMPT) + user_prompt_template = self._text(PLAN_USER_PROMPT_TEMPLATE_EN, PLAN_USER_PROMPT_TEMPLATE) + user_prompt = user_prompt_template.format( simulation_requirement=self.simulation_requirement, total_nodes=context.get('graph_statistics', {}).get('total_nodes', 0), total_edges=context.get('graph_statistics', {}).get('total_edges', 0), @@ -1171,6 +1964,18 @@ def plan_outline( total_entities=context.get('total_entities', 0), related_facts_json=json.dumps(context.get('related_facts', [])[:10], ensure_ascii=False, indent=2), ) + if self.locale == "en": + user_prompt += ( + "\n\nOutput requirements:\n" + "- Return the report title, summary, and section titles/descriptions in English.\n" + "- Keep wording concrete, readable, and directly aligned with the simulation requirement." + ) + else: + user_prompt += ( + "\n\n输出要求:\n" + "- 报告标题、摘要和章节标题/描述都必须使用中文。\n" + "- 表述保持具体、易懂,并直接呼应模拟需求。" + ) try: response = self.llm.chat_json( @@ -1182,7 +1987,7 @@ def plan_outline( ) if progress_callback: - progress_callback("planning", 80, "正在解析大纲结构...") + progress_callback("planning", 80, self._text("Parsing the outline structure...", "正在解析大纲结构...")) # 解析大纲 sections = [] @@ -1193,27 +1998,40 @@ def plan_outline( )) outline = ReportOutline( - title=response.get("title", "模拟分析报告"), + title=self._normalize_outline_title(response.get("title", self._fallback_outline_title())), summary=response.get("summary", ""), sections=sections ) if progress_callback: - progress_callback("planning", 100, "大纲规划完成") + progress_callback("planning", 100, self._text("Outline planning completed", "大纲规划完成")) - logger.info(f"大纲规划完成: {len(sections)} 个章节") + self._log( + "info", + "Outline planning completed: %s sections", + "大纲规划完成: %s 个章节", + len(sections), + ) return outline except Exception as e: - logger.error(f"大纲规划失败: {str(e)}") + self._log( + "error", + "Outline planning failed: %s", + "大纲规划失败: %s", + str(e), + ) # 返回默认大纲(3个章节,作为fallback) return ReportOutline( - title="未来预测报告", - summary="基于模拟预测的未来趋势与风险分析", + title=self._text("Forecast Report", "未来预测报告"), + summary=self._text( + "Trend and risk analysis based on the simulation forecast.", + "基于模拟预测的未来趋势与风险分析", + ), sections=[ - ReportSection(title="预测场景与核心发现"), - ReportSection(title="人群行为预测分析"), - ReportSection(title="趋势展望与风险提示") + ReportSection(title=self._text("Forecast scenarios and key findings", "预测场景与核心发现")), + ReportSection(title=self._text("Audience behavior analysis", "人群行为预测分析")), + ReportSection(title=self._text("Trend outlook and risk signals", "趋势展望与风险提示")) ] ) @@ -1245,19 +2063,13 @@ def _generate_section_react( Returns: 章节内容(Markdown格式) """ - logger.info(f"ReACT生成章节: {section.title}") + self._log("info", "Generating section with ReACT: %s", "ReACT生成章节: %s", section.title) # 记录章节开始日志 if self.report_logger: self.report_logger.log_section_start(section.title, section_index) - system_prompt = SECTION_SYSTEM_PROMPT_TEMPLATE.format( - report_title=outline.title, - report_summary=outline.summary, - simulation_requirement=self.simulation_requirement, - section_title=section.title, - tools_description=self._get_tools_description(), - ) + system_prompt = self._build_section_system_prompt(outline, section) # 构建用户prompt - 每个已完成章节各传入最大4000字 if previous_sections: @@ -1268,9 +2080,9 @@ def _generate_section_react( previous_parts.append(truncated) previous_content = "\n\n---\n\n".join(previous_parts) else: - previous_content = "(这是第一个章节)" + previous_content = self._first_section_placeholder() - user_prompt = SECTION_USER_PROMPT_TEMPLATE.format( + user_prompt = self._build_section_user_prompt( previous_content=previous_content, section_title=section.title, ) @@ -1289,15 +2101,17 @@ def _generate_section_react( all_tools = {"insight_forge", "panorama_search", "quick_search", "interview_agents"} # 报告上下文,用于InsightForge的子问题生成 - report_context = f"章节标题: {section.title}\n模拟需求: {self.simulation_requirement}" + report_context = self._report_context_text(section.title) for iteration in range(max_iterations): if progress_callback: progress_callback( "generating", int((iteration / max_iterations) * 100), - f"深度检索与撰写中 ({tool_calls_count}/{self.MAX_TOOL_CALLS_PER_SECTION})" + self._section_generation_progress_message(tool_calls_count) ) + + messages = self._prune_messages_for_retry(messages) # 调用LLM response = self.llm.chat( @@ -1308,16 +2122,28 @@ def _generate_section_react( # 检查 LLM 返回是否为 None(API 异常或内容为空) if response is None: - logger.warning(f"章节 {section.title} 第 {iteration + 1} 次迭代: LLM 返回 None") + self._log( + "warning", + "Section %s iteration %s: LLM returned None", + "章节 %s 第 %s 次迭代: LLM 返回 None", + section.title, + iteration + 1, + ) # 如果还有迭代次数,添加消息并重试 if iteration < max_iterations - 1: - messages.append({"role": "assistant", "content": "(响应为空)"}) - messages.append({"role": "user", "content": "请继续生成内容。"}) + empty_assistant, retry_user = self._empty_response_retry_messages() + messages.append({"role": "assistant", "content": empty_assistant}) + messages.append({"role": "user", "content": retry_user}) continue # 最后一次迭代也返回 None,跳出循环进入强制收尾 break - logger.debug(f"LLM响应: {response[:200]}...") + self._log( + "debug", + "LLM response preview: %s...", + "LLM响应预览: %s...", + response[:200], + ) # 解析一次,复用结果 tool_calls = self._parse_tool_calls(response) @@ -1327,9 +2153,13 @@ def _generate_section_react( # ── 冲突处理:LLM 同时输出了工具调用和 Final Answer ── if has_tool_calls and has_final_answer: conflict_retries += 1 - logger.warning( - f"章节 {section.title} 第 {iteration+1} 轮: " - f"LLM 同时输出工具调用和 Final Answer(第 {conflict_retries} 次冲突)" + self._log( + "warning", + "Section %s iteration %s: LLM emitted both tool calls and Final Answer (conflict #%s)", + "章节 %s 第 %s 轮: LLM 同时输出工具调用和 Final Answer(第 %s 次冲突)", + section.title, + iteration + 1, + conflict_retries, ) if conflict_retries <= 2: @@ -1337,20 +2167,17 @@ def _generate_section_react( messages.append({"role": "assistant", "content": response}) messages.append({ "role": "user", - "content": ( - "【格式错误】你在一次回复中同时包含了工具调用和 Final Answer,这是不允许的。\n" - "每次回复只能做以下两件事之一:\n" - "- 调用一个工具(输出一个 <tool_call> 块,不要写 Final Answer)\n" - "- 输出最终内容(以 'Final Answer:' 开头,不要包含 <tool_call>)\n" - "请重新回复,只做其中一件事。" - ), + "content": self._react_conflict_retry_message(), }) continue else: # 第三次:降级处理,截断到第一个工具调用,强制执行 - logger.warning( - f"章节 {section.title}: 连续 {conflict_retries} 次冲突," - "降级为截断执行第一个工具调用" + self._log( + "warning", + "Section %s hit %s consecutive format conflicts; degrading to execute only the first tool call", + "章节 %s 连续 %s 次冲突,降级为截断执行第一个工具调用", + section.title, + conflict_retries, ) first_tool_end = response.find('</tool_call>') if first_tool_end != -1: @@ -1377,10 +2204,10 @@ def _generate_section_react( if tool_calls_count < min_tool_calls: messages.append({"role": "assistant", "content": response}) unused_tools = all_tools - used_tools - unused_hint = f"(这些工具还未使用,推荐用一下他们: {', '.join(unused_tools)})" if unused_tools else "" + unused_hint = self._react_unused_tools_hint(unused_tools) messages.append({ "role": "user", - "content": REACT_INSUFFICIENT_TOOLS_MSG.format( + "content": self._react_insufficient_tools_message( tool_calls_count=tool_calls_count, min_tool_calls=min_tool_calls, unused_hint=unused_hint, @@ -1390,7 +2217,13 @@ def _generate_section_react( # 正常结束 final_answer = response.split("Final Answer:")[-1].strip() - logger.info(f"章节 {section.title} 生成完成(工具调用: {tool_calls_count}次)") + self._log( + "info", + "Section %s completed (%s tool calls)", + "章节 %s 生成完成(工具调用: %s次)", + section.title, + tool_calls_count, + ) if self.report_logger: self.report_logger.log_section_content( @@ -1408,7 +2241,7 @@ def _generate_section_react( messages.append({"role": "assistant", "content": response}) messages.append({ "role": "user", - "content": REACT_TOOL_LIMIT_MSG.format( + "content": self._react_tool_limit_message( tool_calls_count=tool_calls_count, max_tool_calls=self.MAX_TOOL_CALLS_PER_SECTION, ), @@ -1418,7 +2251,13 @@ def _generate_section_react( # 只执行第一个工具调用 call = tool_calls[0] if len(tool_calls) > 1: - logger.info(f"LLM 尝试调用 {len(tool_calls)} 个工具,只执行第一个: {call['name']}") + self._log( + "info", + "LLM attempted %s tool calls; executing only the first: %s", + "LLM 尝试调用 %s 个工具,只执行第一个: %s", + len(tool_calls), + call["name"], + ) if self.report_logger: self.report_logger.log_tool_call( @@ -1451,17 +2290,17 @@ def _generate_section_react( unused_tools = all_tools - used_tools unused_hint = "" if unused_tools and tool_calls_count < self.MAX_TOOL_CALLS_PER_SECTION: - unused_hint = REACT_UNUSED_TOOLS_HINT.format(unused_list="、".join(unused_tools)) + unused_hint = self._react_unused_tools_hint(unused_tools) messages.append({"role": "assistant", "content": response}) messages.append({ "role": "user", - "content": REACT_OBSERVATION_TEMPLATE.format( + "content": self._react_observation_message( tool_name=call["name"], result=result, tool_calls_count=tool_calls_count, max_tool_calls=self.MAX_TOOL_CALLS_PER_SECTION, - used_tools_str=", ".join(used_tools), + used_tools=used_tools, unused_hint=unused_hint, ), }) @@ -1473,21 +2312,28 @@ def _generate_section_react( if tool_calls_count < min_tool_calls: # 工具调用次数不足,推荐未用过的工具 unused_tools = all_tools - used_tools - unused_hint = f"(这些工具还未使用,推荐用一下他们: {', '.join(unused_tools)})" if unused_tools else "" + unused_hint = self._react_unused_tools_hint(unused_tools) messages.append({ "role": "user", - "content": REACT_INSUFFICIENT_TOOLS_MSG_ALT.format( + "content": self._react_insufficient_tools_message( tool_calls_count=tool_calls_count, min_tool_calls=min_tool_calls, unused_hint=unused_hint, + alternate=True, ), }) continue # 工具调用已足够,LLM 输出了内容但没带 "Final Answer:" 前缀 # 直接将这段内容作为最终答案,不再空转 - logger.info(f"章节 {section.title} 未检测到 'Final Answer:' 前缀,直接采纳LLM输出作为最终内容(工具调用: {tool_calls_count}次)") + self._log( + "info", + "Section %s had no 'Final Answer:' prefix; accepting raw LLM output as final content (%s tool calls)", + "章节 %s 未检测到 'Final Answer:' 前缀,直接采纳LLM输出作为最终内容(工具调用: %s次)", + section.title, + tool_calls_count, + ) final_answer = response.strip() if self.report_logger: @@ -1500,8 +2346,14 @@ def _generate_section_react( return final_answer # 达到最大迭代次数,强制生成内容 - logger.warning(f"章节 {section.title} 达到最大迭代次数,强制生成") - messages.append({"role": "user", "content": REACT_FORCE_FINAL_MSG}) + self._log( + "warning", + "Section %s reached the maximum iteration count; forcing final generation", + "章节 %s 达到最大迭代次数,强制生成", + section.title, + ) + messages.append({"role": "user", "content": self._react_force_final_message()}) + messages = self._prune_messages_for_retry(messages) response = self.llm.chat( messages=messages, @@ -1511,8 +2363,13 @@ def _generate_section_react( # 检查强制收尾时 LLM 返回是否为 None if response is None: - logger.error(f"章节 {section.title} 强制收尾时 LLM 返回 None,使用默认错误提示") - final_answer = f"(本章节生成失败:LLM 返回空响应,请稍后重试)" + self._log( + "error", + "Section %s returned None during forced finalization; using the fallback error text", + "章节 %s 强制收尾时 LLM 返回 None,使用默认错误提示", + section.title, + ) + final_answer = self._empty_response_fallback_text() elif "Final Answer:" in response: final_answer = response.split("Final Answer:")[-1].strip() else: @@ -1579,7 +2436,7 @@ def generate_report( ReportManager._ensure_report_folder(report_id) # 初始化日志记录器(结构化日志 agent_log.jsonl) - self.report_logger = ReportLogger(report_id) + self.report_logger = ReportLogger(report_id, locale=self.locale) self.report_logger.log_start( simulation_id=self.simulation_id, graph_id=self.graph_id, @@ -1590,15 +2447,15 @@ def generate_report( self.console_logger = ReportConsoleLogger(report_id) ReportManager.update_progress( - report_id, "pending", 0, "初始化报告...", + report_id, "pending", 0, self._text("Initializing report...", "初始化报告..."), completed_sections=[] ) - ReportManager.save_report(report) + ReportManager.save_report(report, locale=self.locale) # 阶段1: 规划大纲 report.status = ReportStatus.PLANNING ReportManager.update_progress( - report_id, "planning", 5, "开始规划报告大纲...", + report_id, "planning", 5, self._text("Starting report outline planning...", "开始规划报告大纲..."), completed_sections=[] ) @@ -1606,7 +2463,7 @@ def generate_report( self.report_logger.log_planning_start() if progress_callback: - progress_callback("planning", 0, "开始规划报告大纲...") + progress_callback("planning", 0, self._text("Starting report outline planning...", "开始规划报告大纲...")) outline = self.plan_outline( progress_callback=lambda stage, prog, msg: @@ -1618,14 +2475,25 @@ def generate_report( self.report_logger.log_planning_complete(outline.to_dict()) # 保存大纲到文件 - ReportManager.save_outline(report_id, outline) + ReportManager.save_outline(report_id, outline, locale=self.locale) ReportManager.update_progress( - report_id, "planning", 15, f"大纲规划完成,共{len(outline.sections)}个章节", + report_id, + "planning", + 15, + self._text( + f"Outline planning completed with {len(outline.sections)} sections", + f"大纲规划完成,共{len(outline.sections)}个章节", + ), completed_sections=[] ) - ReportManager.save_report(report) + ReportManager.save_report(report, locale=self.locale) - logger.info(f"大纲已保存到文件: {report_id}/outline.json") + self._log( + "info", + "Outline saved to file: %s/outline.json", + "大纲已保存到文件: %s/outline.json", + report_id, + ) # 阶段2: 逐章节生成(分章节保存) report.status = ReportStatus.GENERATING @@ -1640,7 +2508,10 @@ def generate_report( # 更新进度 ReportManager.update_progress( report_id, "generating", base_progress, - f"正在生成章节: {section.title} ({section_num}/{total_sections})", + self._text( + f"Generating section: {section.title} ({section_num}/{total_sections})", + f"正在生成章节: {section.title} ({section_num}/{total_sections})", + ), current_section=section.title, completed_sections=completed_section_titles ) @@ -1649,7 +2520,10 @@ def generate_report( progress_callback( "generating", base_progress, - f"正在生成章节: {section.title} ({section_num}/{total_sections})" + self._text( + f"Generating section: {section.title} ({section_num}/{total_sections})", + f"正在生成章节: {section.title} ({section_num}/{total_sections})", + ) ) # 生成主章节内容 @@ -1670,7 +2544,7 @@ def generate_report( generated_sections.append(f"## {section.title}\n\n{section_content}") # 保存章节 - ReportManager.save_section(report_id, section_num, section) + ReportManager.save_section(report_id, section_num, section, locale=self.locale) completed_section_titles.append(section.title) # 记录章节完成日志 @@ -1683,28 +2557,39 @@ def generate_report( full_content=full_section_content.strip() ) - logger.info(f"章节已保存: {report_id}/section_{section_num:02d}.md") + self._log( + "info", + "Section saved: %s/section_%02d.md", + "章节已保存: %s/section_%02d.md", + report_id, + section_num, + ) # 更新进度 ReportManager.update_progress( report_id, "generating", base_progress + int(70 / total_sections), - f"章节 {section.title} 已完成", + self._text(f"Section completed: {section.title}", f"章节 {section.title} 已完成"), current_section=None, completed_sections=completed_section_titles ) # 阶段3: 组装完整报告 if progress_callback: - progress_callback("generating", 95, "正在组装完整报告...") + progress_callback("generating", 95, self._text("Assembling the full report...", "正在组装完整报告...")) ReportManager.update_progress( - report_id, "generating", 95, "正在组装完整报告...", + report_id, "generating", 95, self._text("Assembling the full report...", "正在组装完整报告..."), completed_sections=completed_section_titles ) # 使用ReportManager组装完整报告 - report.markdown_content = ReportManager.assemble_full_report(report_id, outline) + report.markdown_content = ReportManager.assemble_full_report( + report_id, + outline, + locale=self.locale, + report=report, + ) report.status = ReportStatus.COMPLETED report.completed_at = datetime.now().isoformat() @@ -1719,16 +2604,16 @@ def generate_report( ) # 保存最终报告 - ReportManager.save_report(report) + ReportManager.save_report(report, locale=self.locale) ReportManager.update_progress( - report_id, "completed", 100, "报告生成完成", + report_id, "completed", 100, self._text("Report generation completed", "报告生成完成"), completed_sections=completed_section_titles ) if progress_callback: - progress_callback("completed", 100, "报告生成完成") + progress_callback("completed", 100, self._text("Report generation completed", "报告生成完成")) - logger.info(f"报告生成完成: {report_id}") + self._log("info", "Report generation completed: %s", "报告生成完成: %s", report_id) # 关闭控制台日志记录器 if self.console_logger: @@ -1738,7 +2623,7 @@ def generate_report( return report except Exception as e: - logger.error(f"报告生成失败: {str(e)}") + self._log("error", "Report generation failed: %s", "报告生成失败: %s", str(e)) report.status = ReportStatus.FAILED report.error = str(e) @@ -1748,9 +2633,12 @@ def generate_report( # 保存失败状态 try: - ReportManager.save_report(report) + ReportManager.save_report(report, locale=self.locale) ReportManager.update_progress( - report_id, "failed", -1, f"报告生成失败: {str(e)}", + report_id, + "failed", + -1, + self._text(f"Report generation failed: {str(e)}", f"报告生成失败: {str(e)}"), completed_sections=completed_section_titles ) except Exception: @@ -1784,7 +2672,7 @@ def chat( "sources": [信息来源] } """ - logger.info(f"Report Agent对话: {message[:50]}...") + self._log("info", "Report Agent chat: %s...", "Report Agent对话: %s...", message[:50]) chat_history = chat_history or [] @@ -1796,15 +2684,11 @@ def chat( # 限制报告长度,避免上下文过长 report_content = report.markdown_content[:15000] if len(report.markdown_content) > 15000: - report_content += "\n\n... [报告内容已截断] ..." + report_content += self._chat_report_truncated_marker() except Exception as e: - logger.warning(f"获取报告内容失败: {e}") - - system_prompt = CHAT_SYSTEM_PROMPT_TEMPLATE.format( - simulation_requirement=self.simulation_requirement, - report_content=report_content if report_content else "(暂无报告)", - tools_description=self._get_tools_description(), - ) + self._log("warning", "Failed to load report content: %s", "获取报告内容失败: %s", e) + + system_prompt = self._build_chat_system_prompt(report_content=report_content) # 构建消息 messages = [{"role": "system", "content": system_prompt}] @@ -1828,6 +2712,19 @@ def chat( messages=messages, temperature=0.5 ) + + if response is None: + self._log( + "warning", + "Report Agent chat iteration %s returned None; using the empty-response fallback", + "Report Agent 对话第 %s 轮返回 None,改用空响应兜底提示", + iteration + 1, + ) + return { + "response": self._chat_empty_response_text(), + "tool_calls": tool_calls_made, + "sources": [tc.get("parameters", {}).get("query", "") for tc in tool_calls_made] + } # 解析工具调用 tool_calls = self._parse_tool_calls(response) @@ -1857,10 +2754,13 @@ def chat( # 将结果添加到消息 messages.append({"role": "assistant", "content": response}) - observation = "\n".join([f"[{r['tool']}结果]\n{r['result']}" for r in tool_results]) + observation = "\n".join( + self._chat_tool_observation(r["tool"], r["result"]) + for r in tool_results + ) messages.append({ "role": "user", - "content": observation + CHAT_OBSERVATION_SUFFIX + "content": observation + self._chat_observation_suffix() }) # 达到最大迭代,获取最终响应 @@ -1868,6 +2768,14 @@ def chat( messages=messages, temperature=0.5 ) + + if final_response is None: + self._log( + "warning", + "Report Agent chat final response returned None; using the empty-response fallback", + "Report Agent 对话最终响应返回 None,改用空响应兜底提示", + ) + final_response = self._chat_empty_response_text() # 清理响应 clean_response = re.sub(r'<tool_call>.*?</tool_call>', '', final_response, flags=re.DOTALL) @@ -2077,7 +2985,7 @@ def get_agent_log_stream(cls, report_id: str) -> List[Dict[str, Any]]: return result["logs"] @classmethod - def save_outline(cls, report_id: str, outline: ReportOutline) -> None: + def save_outline(cls, report_id: str, outline: ReportOutline, locale: str = "zh") -> None: """ 保存报告大纲 @@ -2088,14 +2996,15 @@ def save_outline(cls, report_id: str, outline: ReportOutline) -> None: with open(cls._get_outline_path(report_id), 'w', encoding='utf-8') as f: json.dump(outline.to_dict(), f, ensure_ascii=False, indent=2) - logger.info(f"大纲已保存: {report_id}") + _log_with_locale("info", locale, "Outline saved: %s", "大纲已保存: %s", report_id) @classmethod def save_section( cls, report_id: str, section_index: int, - section: ReportSection + section: ReportSection, + locale: str = "zh", ) -> str: """ 保存单个章节 @@ -2124,7 +3033,7 @@ def save_section( with open(file_path, 'w', encoding='utf-8') as f: f.write(md_content) - logger.info(f"章节已保存: {report_id}/{file_suffix}") + _log_with_locale("info", locale, "Section saved: %s/%s", "章节已保存: %s/%s", report_id, file_suffix) return file_path @classmethod @@ -2267,7 +3176,102 @@ def get_generated_sections(cls, report_id: str) -> List[Dict[str, Any]]: return sections @classmethod - def assemble_full_report(cls, report_id: str, outline: ReportOutline) -> str: + def _build_report_reference_block(cls, report: Optional[Report], locale: str = "zh") -> str: + """Build a localized reference block for exported Markdown evidence.""" + if report is None: + return "" + + labels = { + "zh": { + "heading": "报告引用信息", + "field": "字段", + "value": "值", + "report_id": "报告 ID", + "simulation_id": "模拟 ID", + "graph_id": "图谱 ID", + "generated_at": "生成时间", + "requirement": "模拟需求", + "report_folder": "报告目录", + "markdown_path": "Markdown 路径", + "checklist": "手动复核清单", + "checklist_keep": "保留本文件以及上面的报告 ID / 模拟 ID 作为后续复核锚点。", + "checklist_compare": "当真实世界结果出现后,对照本报告中的关键预测结论逐项比对。", + "checklist_notes": "记录复核结论时沿用相同 ID 与生成时间,避免混淆不同批次的预测。", + "missing": "暂无", + }, + "en": { + "heading": "Report References", + "field": "Field", + "value": "Value", + "report_id": "Report ID", + "simulation_id": "Simulation ID", + "graph_id": "Graph ID", + "generated_at": "Generated At", + "requirement": "Simulation Requirement", + "report_folder": "Report Folder", + "markdown_path": "Markdown Path", + "checklist": "Manual Verification Checklist", + "checklist_keep": "Keep this file plus the report and simulation IDs above as the stable forecast reference.", + "checklist_compare": "When real-world outcomes are available, compare them against the key forecast claims in this report.", + "checklist_notes": "Reuse the same IDs and generated timestamp when recording verification notes so different forecast runs do not get mixed together.", + "missing": "Unavailable", + }, + } + copy = labels["en"] if locale == "en" else labels["zh"] + fallback = copy["missing"] + generated_at = report.completed_at or report.created_at or fallback + report_folder = cls._get_report_folder(report.report_id) + markdown_path = cls._get_report_markdown_path(report.report_id) + + rows = [ + (copy["report_id"], report.report_id or fallback), + (copy["simulation_id"], report.simulation_id or fallback), + (copy["graph_id"], report.graph_id or fallback), + (copy["generated_at"], generated_at), + (copy["report_folder"], report_folder), + (copy["markdown_path"], markdown_path), + ] + + lines = [ + f"## {copy['heading']}", + "", + f"| {copy['field']} | {copy['value']} |", + "| --- | --- |", + ] + lines.extend(f"| {label} | {value} |" for label, value in rows) + + requirement = (report.simulation_requirement or "").strip() + if requirement: + lines.extend( + [ + "", + f"**{copy['requirement']}**", + "", + requirement, + ] + ) + + lines.extend( + [ + "", + f"### {copy['checklist']}", + "", + f"- {copy['checklist_keep']}", + f"- {copy['checklist_compare']}", + f"- {copy['checklist_notes']}", + ] + ) + lines.extend(["", "---", ""]) + return "\n".join(lines) + + @classmethod + def assemble_full_report( + cls, + report_id: str, + outline: ReportOutline, + locale: str = "zh", + report: Optional[Report] = None, + ) -> str: """ 组装完整报告 @@ -2278,7 +3282,7 @@ def assemble_full_report(cls, report_id: str, outline: ReportOutline) -> str: # 构建报告头部 md_content = f"# {outline.title}\n\n" md_content += f"> {outline.summary}\n\n" - md_content += f"---\n\n" + md_content += cls._build_report_reference_block(report, locale=locale) # 按顺序读取所有章节文件 sections = cls.get_generated_sections(report_id) @@ -2293,7 +3297,7 @@ def assemble_full_report(cls, report_id: str, outline: ReportOutline) -> str: with open(full_path, 'w', encoding='utf-8') as f: f.write(md_content) - logger.info(f"完整报告已组装: {report_id}") + _log_with_locale("info", locale, "Full report assembled: %s", "完整报告已组装: %s", report_id) return md_content @classmethod @@ -2423,7 +3427,7 @@ def _post_process_report(cls, content: str, outline: ReportOutline) -> str: return '\n'.join(result_lines) @classmethod - def save_report(cls, report: Report) -> None: + def save_report(cls, report: Report, locale: str = "zh") -> None: """保存报告元信息和完整报告""" cls._ensure_report_folder(report.report_id) @@ -2433,14 +3437,14 @@ def save_report(cls, report: Report) -> None: # 保存大纲 if report.outline: - cls.save_outline(report.report_id, report.outline) + cls.save_outline(report.report_id, report.outline, locale=locale) # 保存完整Markdown报告 if report.markdown_content: with open(cls._get_report_markdown_path(report.report_id), 'w', encoding='utf-8') as f: f.write(report.markdown_content) - logger.info(f"报告已保存: {report.report_id}") + _log_with_locale("info", locale, "Report saved: %s", "报告已保存: %s", report.report_id) @classmethod def get_report(cls, report_id: str) -> Optional[Report]: @@ -2544,7 +3548,7 @@ def list_reports(cls, simulation_id: Optional[str] = None, limit: int = 50) -> L return reports[:limit] @classmethod - def delete_report(cls, report_id: str) -> bool: + def delete_report(cls, report_id: str, locale: str = "zh") -> bool: """删除报告(整个文件夹)""" import shutil @@ -2553,7 +3557,7 @@ def delete_report(cls, report_id: str) -> bool: # 新格式:删除整个文件夹 if os.path.exists(folder_path) and os.path.isdir(folder_path): shutil.rmtree(folder_path) - logger.info(f"报告文件夹已删除: {report_id}") + _log_with_locale("info", locale, "Report folder deleted: %s", "报告文件夹已删除: %s", report_id) return True # 兼容旧格式:删除单独的文件 diff --git a/backend/app/services/simulation_config_generator.py b/backend/app/services/simulation_config_generator.py index cc362508..07031ca8 100644 --- a/backend/app/services/simulation_config_generator.py +++ b/backend/app/services/simulation_config_generator.py @@ -19,11 +19,25 @@ from openai import OpenAI from ..config import Config +from ..i18n import tr from ..utils.logger import get_logger +from ..utils.llm_client import LLMClient from .zep_entity_reader import EntityNode, ZepEntityReader logger = get_logger('mirofish.simulation_config') + +def _supports_json_mode_error(exc: Exception) -> bool: + text = str(exc).lower() + markers = ( + "response_format", + "json_object", + "unsupported", + "not support", + "invalid parameter", + ) + return any(marker in text for marker in markers) + # 中国作息时间配置(北京时间) CHINA_TIMEZONE_CONFIG = { # 深夜时段(几乎无人活动) @@ -225,19 +239,55 @@ def __init__( self, api_key: Optional[str] = None, base_url: Optional[str] = None, - model_name: Optional[str] = None + model_name: Optional[str] = None, + locale: str = "zh", ): self.api_key = api_key or Config.LLM_API_KEY self.base_url = base_url or Config.LLM_BASE_URL self.model_name = model_name or Config.LLM_MODEL_NAME + self.locale = "en" if locale == "en" else "zh" if not self.api_key: - raise ValueError("LLM_API_KEY 未配置") + raise ValueError(tr("config.llm_key_missing", self.locale)) self.client = OpenAI( api_key=self.api_key, base_url=self.base_url ) + + def _request_json_completion( + self, + messages: List[Dict[str, str]], + temperature: float, + ) -> Dict[str, Any]: + kwargs = { + "model": self.model_name, + "messages": messages, + "temperature": temperature, + "response_format": {"type": "json_object"}, + } + + try: + response = self.client.chat.completions.create(**kwargs) + except Exception as exc: + if not _supports_json_mode_error(exc): + raise + + logger.warning(tr("llm.json_mode_retry", self.locale)) + kwargs.pop("response_format", None) + response = self.client.chat.completions.create(**kwargs) + + content = response.choices[0].message.content or "" + finish_reason = response.choices[0].finish_reason + if finish_reason == 'length': + logger.warning(tr("simulation.config_output_truncated", self.locale)) + content = self._fix_truncated_json(content) + + parsed_content = LLMClient._extract_json_payload(content) + return { + "content": parsed_content, + "finish_reason": finish_reason, + } def generate_config( self, @@ -268,7 +318,11 @@ def generate_config( Returns: SimulationParameters: 完整的模拟参数 """ - logger.info(f"开始智能生成模拟配置: simulation_id={simulation_id}, 实体数={len(entities)}") + logger.info( + "Starting simulation config generation: simulation_id=%s, entity_count=%s", + simulation_id, + len(entities), + ) # 计算总步骤数 num_batches = math.ceil(len(entities) / self.AGENTS_PER_BATCH) @@ -292,17 +346,21 @@ def report_progress(step: int, message: str): reasoning_parts = [] # ========== 步骤1: 生成时间配置 ========== - report_progress(1, "生成时间配置...") + report_progress(1, self._tr("Generating time configuration...", "生成时间配置...")) num_entities = len(entities) time_config_result = self._generate_time_config(context, num_entities) time_config = self._parse_time_config(time_config_result, num_entities) - reasoning_parts.append(f"时间配置: {time_config_result.get('reasoning', '成功')}") + reasoning_parts.append( + f"{self._tr('Time config', '时间配置')}: {time_config_result.get('reasoning', self._success_reasoning())}" + ) # ========== 步骤2: 生成事件配置 ========== - report_progress(2, "生成事件配置和热点话题...") + report_progress(2, self._tr("Generating event config and hot topics...", "生成事件配置和热点话题...")) event_config_result = self._generate_event_config(context, simulation_requirement, entities) event_config = self._parse_event_config(event_config_result) - reasoning_parts.append(f"事件配置: {event_config_result.get('reasoning', '成功')}") + reasoning_parts.append( + f"{self._tr('Event config', '事件配置')}: {event_config_result.get('reasoning', self._success_reasoning())}" + ) # ========== 步骤3-N: 分批生成Agent配置 ========== all_agent_configs = [] @@ -313,7 +371,7 @@ def report_progress(step: int, message: str): report_progress( 3 + batch_idx, - f"生成Agent配置 ({start_idx + 1}-{end_idx}/{len(entities)})..." + self._format_agent_batch_progress(start_idx + 1, end_idx, len(entities)) ) batch_configs = self._generate_agent_configs_batch( @@ -324,16 +382,21 @@ def report_progress(step: int, message: str): ) all_agent_configs.extend(batch_configs) - reasoning_parts.append(f"Agent配置: 成功生成 {len(all_agent_configs)} 个") + reasoning_parts.append(self._agent_config_reasoning(len(all_agent_configs))) # ========== 为初始帖子分配发布者 Agent ========== - logger.info("为初始帖子分配合适的发布者 Agent...") + logger.info( + self._tr( + "Assigning suitable poster agents to initial posts...", + "为初始帖子分配合适的发布者 Agent...", + ) + ) event_config = self._assign_initial_post_agents(event_config, all_agent_configs) assigned_count = len([p for p in event_config.initial_posts if p.get("poster_agent_id") is not None]) - reasoning_parts.append(f"初始帖子分配: {assigned_count} 个帖子已分配发布者") + reasoning_parts.append(self._initial_post_reasoning(assigned_count)) # ========== 最后一步: 生成平台配置 ========== - report_progress(total_steps, "生成平台配置...") + report_progress(total_steps, self._tr("Generating platform configuration...", "生成平台配置...")) twitter_config = None reddit_config = None @@ -373,7 +436,10 @@ def report_progress(step: int, message: str): generation_reasoning=" | ".join(reasoning_parts) ) - logger.info(f"模拟配置生成完成: {len(params.agent_configs)} 个Agent配置") + logger.info( + "Simulation config generation completed: agent_count=%s", + len(params.agent_configs), + ) return params @@ -389,10 +455,16 @@ def _build_context( entity_summary = self._summarize_entities(entities) # 构建上下文 - context_parts = [ - f"## 模拟需求\n{simulation_requirement}", - f"\n## 实体信息 ({len(entities)}个)\n{entity_summary}", - ] + if self.locale == "en": + context_parts = [ + f"## Simulation Requirement\n{simulation_requirement}", + f"\n## Entity Information ({len(entities)} total)\n{entity_summary}", + ] + else: + context_parts = [ + f"## 模拟需求\n{simulation_requirement}", + f"\n## 实体信息 ({len(entities)}个)\n{entity_summary}", + ] current_length = sum(len(p) for p in context_parts) remaining_length = self.MAX_CONTEXT_LENGTH - current_length - 500 # 留500字符余量 @@ -400,10 +472,15 @@ def _build_context( if remaining_length > 0 and document_text: doc_text = document_text[:remaining_length] if len(document_text) > remaining_length: - doc_text += "\n...(文档已截断)" - context_parts.append(f"\n## 原始文档内容\n{doc_text}") + doc_text += self._tr("\n...(document truncated)", "\n...(文档已截断)") + context_parts.append( + f"\n## {self._tr('Source Document Content', '原始文档内容')}\n{doc_text}" + ) return "\n".join(context_parts) + + def _unknown_label(self) -> str: + return self._tr("Unknown", "未知") def _summarize_entities(self, entities: List[EntityNode]) -> str: """生成实体摘要""" @@ -412,13 +489,15 @@ def _summarize_entities(self, entities: List[EntityNode]) -> str: # 按类型分组 by_type: Dict[str, List[EntityNode]] = {} for e in entities: - t = e.get_entity_type() or "Unknown" + t = e.get_entity_type() or self._unknown_label() if t not in by_type: by_type[t] = [] by_type[t].append(e) for entity_type, type_entities in by_type.items(): - lines.append(f"\n### {entity_type} ({len(type_entities)}个)") + lines.append( + f"\n### {entity_type} ({self._entity_count_label(len(type_entities))})" + ) # 使用配置的显示数量和摘要长度 display_count = self.ENTITIES_PER_TYPE_DISPLAY summary_len = self.ENTITY_SUMMARY_LENGTH @@ -426,7 +505,10 @@ def _summarize_entities(self, entities: List[EntityNode]) -> str: summary_preview = (e.summary[:summary_len] + "...") if len(e.summary) > summary_len else e.summary lines.append(f"- {e.name}: {summary_preview}") if len(type_entities) > display_count: - lines.append(f" ... 还有 {len(type_entities) - display_count} 个") + remaining = len(type_entities) - display_count + lines.append( + self._tr(f" ... {remaining} more", f" ... 还有 {remaining} 个") + ) return "\n".join(lines) @@ -439,30 +521,25 @@ def _call_llm_with_retry(self, prompt: str, system_prompt: str) -> Dict[str, Any for attempt in range(max_attempts): try: - response = self.client.chat.completions.create( - model=self.model_name, + completion = self._request_json_completion( messages=[ {"role": "system", "content": system_prompt}, - {"role": "user", "content": prompt} + {"role": "user", "content": prompt}, ], - response_format={"type": "json_object"}, - temperature=0.7 - (attempt * 0.1) # 每次重试降低温度 - # 不设置max_tokens,让LLM自由发挥 + temperature=0.7 - (attempt * 0.1), ) - - content = response.choices[0].message.content - finish_reason = response.choices[0].finish_reason - - # 检查是否被截断 - if finish_reason == 'length': - logger.warning(f"LLM输出被截断 (attempt {attempt+1})") - content = self._fix_truncated_json(content) + content = completion["content"] # 尝试解析JSON try: return json.loads(content) except json.JSONDecodeError as e: - logger.warning(f"JSON解析失败 (attempt {attempt+1}): {str(e)[:80]}") + logger.warning( + self._tr( + f"JSON parsing failed (attempt {attempt+1}): {str(e)[:80]}", + f"JSON解析失败 (attempt {attempt+1}): {str(e)[:80]}", + ) + ) # 尝试修复JSON fixed = self._try_fix_config_json(content) @@ -472,12 +549,17 @@ def _call_llm_with_retry(self, prompt: str, system_prompt: str) -> Dict[str, Any last_error = e except Exception as e: - logger.warning(f"LLM调用失败 (attempt {attempt+1}): {str(e)[:80]}") + logger.warning( + self._tr( + f"LLM call failed (attempt {attempt+1}): {str(e)[:80]}", + f"LLM调用失败 (attempt {attempt+1}): {str(e)[:80]}", + ) + ) last_error = e import time time.sleep(2 * (attempt + 1)) - raise last_error or Exception("LLM调用失败") + raise last_error or Exception(self._tr("LLM call failed", "LLM调用失败")) def _fix_truncated_json(self, content: str) -> str: """修复被截断的JSON""" @@ -520,13 +602,13 @@ def fix_string(match): try: return json.loads(json_str) - except: + except Exception: # 尝试移除所有控制字符 json_str = re.sub(r'[\x00-\x1f\x7f-\x9f]', ' ', json_str) json_str = re.sub(r'\s+', ' ', json_str) try: return json.loads(json_str) - except: + except Exception: pass return None @@ -538,8 +620,78 @@ def _generate_time_config(self, context: str, num_entities: int) -> Dict[str, An # 计算最大允许值(80%的agent数) max_agents_allowed = max(1, int(num_entities * 0.9)) + prompt, system_prompt = self._build_time_config_prompt( + context_truncated, + max_agents_allowed, + ) - prompt = f"""基于以下模拟需求,生成时间模拟配置。 + try: + return self._call_llm_with_retry(prompt, system_prompt) + except Exception as e: + logger.warning( + self._tr( + f"Time-config LLM generation failed: {e}, falling back to defaults", + f"时间配置LLM生成失败: {e}, 使用默认配置", + ) + ) + return self._get_default_time_config(num_entities) + + def _build_time_config_prompt(self, context_truncated: str, max_agents_allowed: int) -> tuple[str, str]: + if self.locale == "en": + prompt = f"""Generate a time-configuration JSON for this social simulation. + +{context_truncated} + +## Task +Return only JSON for the time configuration. + +### Baseline guidance (adapt these values to the event and participant groups): +- Assume the default audience mainly follows China Standard Time activity patterns +- 00:00-05:59 is nearly inactive (activity multiplier 0.05) +- 06:00-08:59 gradually becomes active (activity multiplier 0.4) +- 09:00-18:59 is moderately active (activity multiplier 0.7) +- 19:00-22:59 is the peak period (activity multiplier 1.5) +- Activity declines after 23:00 (activity multiplier 0.5) +- General pattern: lowest overnight, increasing in the morning, moderate during work hours, peak in the evening +- Important: the example values below are references only. Adjust them to the event type and the participant mix. + - Example: student groups may peak from 21:00-23:00; media may stay active all day; official institutions may mainly post during work hours. + - Example: a breaking event may still generate late-night activity, so off_peak_hours can be shorter. + +### JSON schema (no markdown) + +Example: +{{ + "total_simulation_hours": 72, + "minutes_per_round": 60, + "agents_per_hour_min": 5, + "agents_per_hour_max": 50, + "peak_hours": [19, 20, 21, 22], + "off_peak_hours": [0, 1, 2, 3, 4, 5], + "morning_hours": [6, 7, 8], + "work_hours": [9, 10, 11, 12, 13, 14, 15, 16, 17, 18], + "reasoning": "Brief explanation for the chosen time configuration" +}} + +Field notes: +- total_simulation_hours (int): total duration, 24-168 hours. Breaking events tend to be shorter; long-running topics can be longer. +- minutes_per_round (int): duration per round, 30-120 minutes, usually 60. +- agents_per_hour_min (int): minimum active agents per hour (range: 1-{max_agents_allowed}) +- agents_per_hour_max (int): maximum active agents per hour (range: 1-{max_agents_allowed}) +- peak_hours (int[]): peak hours tuned to the participant groups +- off_peak_hours (int[]): low-activity hours, usually late night / early morning +- morning_hours (int[]): morning hours +- work_hours (int[]): workday hours +- reasoning (string): brief explanation of why this schedule fits the scenario + +Language requirement: +- Write the reasoning text in English.""" + system_prompt = ( + "You are a social-media simulation expert. Return strict JSON only. " + "The time configuration should default to China Standard Time activity patterns unless the scenario suggests otherwise. " + "Any free-text output fields must be written in English." + ) + else: + prompt = f"""基于以下模拟需求,生成时间模拟配置。 {context_truncated} @@ -583,14 +735,9 @@ def _generate_time_config(self, context: str, num_entities: int) -> Dict[str, An - morning_hours (int数组): 早间时段 - work_hours (int数组): 工作时段 - reasoning (string): 简要说明为什么这样配置""" + system_prompt = "你是社交媒体模拟专家。返回纯JSON格式,时间配置需符合中国人作息习惯。" - system_prompt = "你是社交媒体模拟专家。返回纯JSON格式,时间配置需符合中国人作息习惯。" - - try: - return self._call_llm_with_retry(prompt, system_prompt) - except Exception as e: - logger.warning(f"时间配置LLM生成失败: {e}, 使用默认配置") - return self._get_default_time_config(num_entities) + return prompt, system_prompt def _get_default_time_config(self, num_entities: int) -> Dict[str, Any]: """获取默认时间配置(中国人作息)""" @@ -603,7 +750,10 @@ def _get_default_time_config(self, num_entities: int) -> Dict[str, Any]: "off_peak_hours": [0, 1, 2, 3, 4, 5], "morning_hours": [6, 7, 8], "work_hours": [9, 10, 11, 12, 13, 14, 15, 16, 17, 18], - "reasoning": "使用默认中国人作息配置(每轮1小时)" + "reasoning": self._tr( + "Using the default China Standard Time activity pattern (1 hour per round)", + "使用默认中国人作息配置(每轮1小时)", + ), } def _parse_time_config(self, result: Dict[str, Any], num_entities: int) -> TimeSimulationConfig: @@ -611,20 +761,35 @@ def _parse_time_config(self, result: Dict[str, Any], num_entities: int) -> TimeS # 获取原始值 agents_per_hour_min = result.get("agents_per_hour_min", max(1, num_entities // 15)) agents_per_hour_max = result.get("agents_per_hour_max", max(5, num_entities // 5)) - + # 验证并修正:确保不超过总agent数 if agents_per_hour_min > num_entities: - logger.warning(f"agents_per_hour_min ({agents_per_hour_min}) 超过总Agent数 ({num_entities}),已修正") + logger.warning( + self._tr( + f"agents_per_hour_min ({agents_per_hour_min}) exceeded the total agent count ({num_entities}); adjusted automatically", + f"agents_per_hour_min ({agents_per_hour_min}) 超过总Agent数 ({num_entities}),已修正", + ) + ) agents_per_hour_min = max(1, num_entities // 10) - + if agents_per_hour_max > num_entities: - logger.warning(f"agents_per_hour_max ({agents_per_hour_max}) 超过总Agent数 ({num_entities}),已修正") + logger.warning( + self._tr( + f"agents_per_hour_max ({agents_per_hour_max}) exceeded the total agent count ({num_entities}); adjusted automatically", + f"agents_per_hour_max ({agents_per_hour_max}) 超过总Agent数 ({num_entities}),已修正", + ) + ) agents_per_hour_max = max(agents_per_hour_min + 1, num_entities // 2) - + # 确保 min < max if agents_per_hour_min >= agents_per_hour_max: agents_per_hour_min = max(1, agents_per_hour_max // 2) - logger.warning(f"agents_per_hour_min >= max,已修正为 {agents_per_hour_min}") + logger.warning( + self._tr( + f"agents_per_hour_min was >= max; adjusted to {agents_per_hour_min}", + f"agents_per_hour_min >= max,已修正为 {agents_per_hour_min}", + ) + ) return TimeSimulationConfig( total_simulation_hours=result.get("total_simulation_hours", 72), @@ -650,14 +815,15 @@ def _generate_event_config( """生成事件配置""" # 获取可用的实体类型列表,供 LLM 参考 + unknown_label = self._unknown_label() entity_types_available = list(set( - e.get_entity_type() or "Unknown" for e in entities + e.get_entity_type() or unknown_label for e in entities )) # 为每种类型列出代表性实体名称 type_examples = {} for e in entities: - etype = e.get_entity_type() or "Unknown" + etype = e.get_entity_type() or unknown_label if etype not in type_examples: type_examples[etype] = [] if len(type_examples[etype]) < 3: @@ -671,11 +837,77 @@ def _generate_event_config( # 使用配置的上下文截断长度 context_truncated = context[:self.EVENT_CONFIG_CONTEXT_LENGTH] - prompt = f"""基于以下模拟需求,生成事件配置。 + prompt, system_prompt = self._build_event_config_prompt( + context=context_truncated, + simulation_requirement=simulation_requirement, + type_info=type_info, + ) + + try: + return self._call_llm_with_retry(prompt, system_prompt) + except Exception as e: + logger.warning( + self._tr( + f"Event-config LLM generation failed: {e}, falling back to defaults", + f"事件配置LLM生成失败: {e}, 使用默认配置", + ) + ) + return { + "hot_topics": [], + "narrative_direction": "", + "initial_posts": [], + "reasoning": self._tr("Using default configuration", "使用默认配置"), + } + + def _build_event_config_prompt( + self, + context: str, + simulation_requirement: str, + type_info: str, + ) -> tuple[str, str]: + if self.locale == "en": + prompt = f"""Generate the event configuration JSON for this simulation. + +Simulation requirement: {simulation_requirement} + +{context} + +## Available entity types and examples +{type_info} + +## Task +Return event-configuration JSON that: +- extracts hot-topic keywords +- describes the expected narrative direction +- creates initial posts, and every post must include `poster_type` + +Important: `poster_type` must be chosen from the available entity types listed above so each initial post can be assigned to a matching agent. +For example: official statements should come from Official/University types, news posts from MediaOutlet, student viewpoints from Student. + +Language requirement: +- Write `narrative_direction`, every `initial_posts[].content` value, and `reasoning` in English. + +Return JSON only (no markdown): +{{ + "hot_topics": ["keyword1", "keyword2"], + "narrative_direction": "<expected narrative development>", + "initial_posts": [ + {{"content": "post content", "poster_type": "entity type from the available list"}}, + ... + ], + "reasoning": "<brief explanation>" +}}""" + system_prompt = ( + "You are a public-opinion analysis expert. Return strict JSON only. " + "poster_type must match one of the available entity types exactly. " + "Any free-text output fields must be written in English." + ) + else: + prompt = f"""基于以下模拟需求,生成事件配置。 模拟需求: {simulation_requirement} -{context_truncated} +{context} ## 可用实体类型及示例 {type_info} @@ -699,19 +931,9 @@ def _generate_event_config( ], "reasoning": "<简要说明>" }}""" + system_prompt = "你是舆论分析专家。返回纯JSON格式。注意 poster_type 必须精确匹配可用实体类型。" - system_prompt = "你是舆论分析专家。返回纯JSON格式。注意 poster_type 必须精确匹配可用实体类型。" - - try: - return self._call_llm_with_retry(prompt, system_prompt) - except Exception as e: - logger.warning(f"事件配置LLM生成失败: {e}, 使用默认配置") - return { - "hot_topics": [], - "narrative_direction": "", - "initial_posts": [], - "reasoning": "使用默认配置" - } + return prompt, system_prompt def _parse_event_config(self, result: Dict[str, Any]) -> EventConfig: """解析事件配置结果""" @@ -735,6 +957,7 @@ def _assign_initial_post_agents( if not event_config.initial_posts: return event_config + unknown_label = self._unknown_label() # 按实体类型建立 agent 索引 agents_by_type: Dict[str, List[AgentActivityConfig]] = {} for agent in agent_configs: @@ -788,7 +1011,12 @@ def _assign_initial_post_agents( # 3. 如果仍未找到,使用影响力最高的 agent if matched_agent_id is None: - logger.warning(f"未找到类型 '{poster_type}' 的匹配 Agent,使用影响力最高的 Agent") + logger.warning( + self._tr( + f"No matching agent found for poster_type '{poster_type}'; using the highest-influence agent", + f"未找到类型 '{poster_type}' 的匹配 Agent,使用影响力最高的 Agent", + ) + ) if agent_configs: # 按影响力排序,选择影响力最高的 sorted_agents = sorted(agent_configs, key=lambda a: a.influence_weight, reverse=True) @@ -798,11 +1026,16 @@ def _assign_initial_post_agents( updated_posts.append({ "content": content, - "poster_type": post.get("poster_type", "Unknown"), + "poster_type": post.get("poster_type", unknown_label), "poster_agent_id": matched_agent_id }) - - logger.info(f"初始帖子分配: poster_type='{poster_type}' -> agent_id={matched_agent_id}") + + logger.info( + self._tr( + f"Initial post assignment: poster_type='{poster_type}' -> agent_id={matched_agent_id}", + f"初始帖子分配: poster_type='{poster_type}' -> agent_id={matched_agent_id}", + ) + ) event_config.initial_posts = updated_posts return event_config @@ -817,17 +1050,109 @@ def _generate_agent_configs_batch( """分批生成Agent配置""" # 构建实体信息(使用配置的摘要长度) + unknown_label = self._unknown_label() entity_list = [] summary_len = self.AGENT_SUMMARY_LENGTH for i, e in enumerate(entities): entity_list.append({ "agent_id": start_idx + i, "entity_name": e.name, - "entity_type": e.get_entity_type() or "Unknown", + "entity_type": e.get_entity_type() or unknown_label, "summary": e.summary[:summary_len] if e.summary else "" }) - prompt = f"""基于以下信息,为每个实体生成社交媒体活动配置。 + prompt, system_prompt = self._build_agent_config_prompt( + entity_list=entity_list, + simulation_requirement=simulation_requirement, + ) + + try: + result = self._call_llm_with_retry(prompt, system_prompt) + llm_configs = {cfg["agent_id"]: cfg for cfg in result.get("agent_configs", [])} + except Exception as e: + logger.warning( + self._tr( + f"Agent-config batch generation failed: {e}, falling back to rule-based defaults", + f"Agent配置批次LLM生成失败: {e}, 使用规则生成", + ) + ) + llm_configs = {} + + # 构建AgentActivityConfig对象 + configs = [] + for i, entity in enumerate(entities): + agent_id = start_idx + i + cfg = llm_configs.get(agent_id, {}) + + # 如果LLM没有生成,使用规则生成 + if not cfg: + cfg = self._generate_agent_config_by_rule(entity) + + config = AgentActivityConfig( + agent_id=agent_id, + entity_uuid=entity.uuid, + entity_name=entity.name, + entity_type=entity.get_entity_type() or unknown_label, + activity_level=cfg.get("activity_level", 0.5), + posts_per_hour=cfg.get("posts_per_hour", 0.5), + comments_per_hour=cfg.get("comments_per_hour", 1.0), + active_hours=cfg.get("active_hours", list(range(9, 23))), + response_delay_min=cfg.get("response_delay_min", 5), + response_delay_max=cfg.get("response_delay_max", 60), + sentiment_bias=cfg.get("sentiment_bias", 0.0), + stance=cfg.get("stance", "neutral"), + influence_weight=cfg.get("influence_weight", 1.0) + ) + configs.append(config) + + return configs + + def _build_agent_config_prompt( + self, + entity_list: List[Dict[str, Any]], + simulation_requirement: str, + ) -> tuple[str, str]: + if self.locale == "en": + prompt = f"""Generate social-media activity configurations for each entity below. + +Simulation requirement: {simulation_requirement} + +## Entity list +```json +{json.dumps(entity_list, ensure_ascii=False, indent=2)} +``` + +## Task +Generate one activity config per entity. Guidance: +- Follow China Standard Time activity patterns by default: almost inactive from 00:00-05:59, most active from 19:00-22:59 +- Official institutions (University/GovernmentAgency): low activity (0.1-0.3), work hours only (9-17), slow response (60-240 minutes), high influence (2.5-3.0) +- Media (MediaOutlet): medium activity (0.4-0.6), active all day (8-23), fast response (5-30 minutes), high influence (2.0-2.5) +- Individuals (Student/Person/Alumni): high activity (0.6-0.9), mostly evenings (18-23), fast response (1-15 minutes), lower influence (0.8-1.2) +- Public figures / experts: medium activity (0.4-0.6), medium-high influence (1.5-2.0) + +Return JSON only (no markdown): +{{ + "agent_configs": [ + {{ + "agent_id": <must match the input>, + "activity_level": <0.0-1.0>, + "posts_per_hour": <posting rate>, + "comments_per_hour": <commenting rate>, + "active_hours": [<active hour list tuned to the schedule>], + "response_delay_min": <minimum response delay in minutes>, + "response_delay_max": <maximum response delay in minutes>, + "sentiment_bias": <-1.0 to 1.0>, + "stance": "<supportive/opposing/neutral/observer>", + "influence_weight": <influence weight> + }} + ] +}}""" + system_prompt = ( + "You are a social-media behavior analyst. Return strict JSON only. " + "Configurations should default to China Standard Time activity patterns." + ) + else: + prompt = f"""基于以下信息,为每个实体生成社交媒体活动配置。 模拟需求: {simulation_requirement} @@ -862,48 +1187,13 @@ def _generate_agent_configs_batch( ... ] }}""" + system_prompt = "你是社交媒体行为分析专家。返回纯JSON,配置需符合中国人作息习惯。" - system_prompt = "你是社交媒体行为分析专家。返回纯JSON,配置需符合中国人作息习惯。" - - try: - result = self._call_llm_with_retry(prompt, system_prompt) - llm_configs = {cfg["agent_id"]: cfg for cfg in result.get("agent_configs", [])} - except Exception as e: - logger.warning(f"Agent配置批次LLM生成失败: {e}, 使用规则生成") - llm_configs = {} - - # 构建AgentActivityConfig对象 - configs = [] - for i, entity in enumerate(entities): - agent_id = start_idx + i - cfg = llm_configs.get(agent_id, {}) - - # 如果LLM没有生成,使用规则生成 - if not cfg: - cfg = self._generate_agent_config_by_rule(entity) - - config = AgentActivityConfig( - agent_id=agent_id, - entity_uuid=entity.uuid, - entity_name=entity.name, - entity_type=entity.get_entity_type() or "Unknown", - activity_level=cfg.get("activity_level", 0.5), - posts_per_hour=cfg.get("posts_per_hour", 0.5), - comments_per_hour=cfg.get("comments_per_hour", 1.0), - active_hours=cfg.get("active_hours", list(range(9, 23))), - response_delay_min=cfg.get("response_delay_min", 5), - response_delay_max=cfg.get("response_delay_max", 60), - sentiment_bias=cfg.get("sentiment_bias", 0.0), - stance=cfg.get("stance", "neutral"), - influence_weight=cfg.get("influence_weight", 1.0) - ) - configs.append(config) - - return configs + return prompt, system_prompt def _generate_agent_config_by_rule(self, entity: EntityNode) -> Dict[str, Any]: """基于规则生成单个Agent配置(中国人作息)""" - entity_type = (entity.get_entity_type() or "Unknown").lower() + entity_type = (entity.get_entity_type() or self._unknown_label()).lower() if entity_type in ["university", "governmentagency", "ngo"]: # 官方机构:工作时间活动,低频率,高影响力 @@ -983,5 +1273,28 @@ def _generate_agent_config_by_rule(self, entity: EntityNode) -> Dict[str, Any]: "stance": "neutral", "influence_weight": 1.0 } - + def _tr(self, en_text: str, zh_text: str) -> str: + return en_text if self.locale == "en" else zh_text + + def _success_reasoning(self) -> str: + return self._tr("success", "成功") + + def _entity_count_label(self, count: int) -> str: + return f"{count} total" if self.locale == "en" else f"{count}个" + + def _format_agent_batch_progress(self, start: int, end: int, total: int) -> str: + if self.locale == "en": + return f"Generating agent configs ({start}-{end}/{total})..." + return f"生成Agent配置 ({start}-{end}/{total})..." + + def _agent_config_reasoning(self, count: int) -> str: + if self.locale == "en": + return f"Agent config: generated {count} entries successfully" + return f"Agent配置: 成功生成 {count} 个" + + def _initial_post_reasoning(self, count: int) -> str: + if self.locale == "en": + return f"Initial post assignment: assigned poster agents for {count} post(s)" + return f"初始帖子分配: {count} 个帖子已分配发布者" + diff --git a/backend/app/services/simulation_ipc.py b/backend/app/services/simulation_ipc.py index 9d70d0be..36299b37 100644 --- a/backend/app/services/simulation_ipc.py +++ b/backend/app/services/simulation_ipc.py @@ -17,6 +17,7 @@ from datetime import datetime from enum import Enum +from ..i18n import get_locale, tr from ..utils.logger import get_logger logger = get_logger('mirofish.simulation_ipc') @@ -99,7 +100,7 @@ class SimulationIPCClient: 用于向模拟进程发送命令并等待响应 """ - def __init__(self, simulation_dir: str): + def __init__(self, simulation_dir: str, locale: str | None = None): """ 初始化IPC客户端 @@ -107,12 +108,18 @@ def __init__(self, simulation_dir: str): simulation_dir: 模拟数据目录 """ self.simulation_dir = simulation_dir + self.locale = locale if locale in {"zh", "en"} else None self.commands_dir = os.path.join(simulation_dir, "ipc_commands") self.responses_dir = os.path.join(simulation_dir, "ipc_responses") # 确保目录存在 os.makedirs(self.commands_dir, exist_ok=True) os.makedirs(self.responses_dir, exist_ok=True) + + def _resolve_locale(self) -> str: + if self.locale in {"zh", "en"}: + return self.locale + return get_locale() def send_command( self, @@ -142,13 +149,21 @@ def send_command( command_type=command_type, args=args ) + locale = self._resolve_locale() # 写入命令文件 command_file = os.path.join(self.commands_dir, f"{command_id}.json") with open(command_file, 'w', encoding='utf-8') as f: json.dump(command.to_dict(), f, ensure_ascii=False, indent=2) - logger.info(f"发送IPC命令: {command_type.value}, command_id={command_id}") + logger.info( + tr( + "simulation.ipc_command_sent", + locale, + command_type=command_type.value, + command_id=command_id, + ) + ) # 等待响应 response_file = os.path.join(self.responses_dir, f"{command_id}.json") @@ -168,15 +183,34 @@ def send_command( except OSError: pass - logger.info(f"收到IPC响应: command_id={command_id}, status={response.status.value}") + logger.info( + tr( + "simulation.ipc_response_received", + locale, + command_id=command_id, + status=response.status.value, + ) + ) return response except (json.JSONDecodeError, KeyError) as e: - logger.warning(f"解析响应失败: {e}") + logger.warning( + tr( + "simulation.ipc_response_parse_failed", + locale, + error=e, + ) + ) time.sleep(poll_interval) # 超时 - logger.error(f"等待IPC响应超时: command_id={command_id}") + logger.error( + tr( + "simulation.ipc_timeout", + locale, + timeout=timeout, + ) + ) # 清理命令文件 try: @@ -184,7 +218,7 @@ def send_command( except OSError: pass - raise TimeoutError(f"等待命令响应超时 ({timeout}秒)") + raise TimeoutError(tr("simulation.ipc_timeout", locale, timeout=timeout)) def send_interview( self, @@ -292,7 +326,7 @@ class SimulationIPCServer: 轮询命令目录,执行命令并返回响应 """ - def __init__(self, simulation_dir: str): + def __init__(self, simulation_dir: str, locale: str | None = None): """ 初始化IPC服务器 @@ -300,6 +334,7 @@ def __init__(self, simulation_dir: str): simulation_dir: 模拟数据目录 """ self.simulation_dir = simulation_dir + self.locale = locale or get_locale() self.commands_dir = os.path.join(simulation_dir, "ipc_commands") self.responses_dir = os.path.join(simulation_dir, "ipc_responses") @@ -354,7 +389,14 @@ def poll_commands(self) -> Optional[IPCCommand]: data = json.load(f) return IPCCommand.from_dict(data) except (json.JSONDecodeError, KeyError, OSError) as e: - logger.warning(f"读取命令文件失败: {filepath}, {e}") + logger.warning( + tr( + "simulation.ipc_command_file_read_failed", + self.locale, + path=filepath, + error=e, + ) + ) continue return None diff --git a/backend/app/services/simulation_manager.py b/backend/app/services/simulation_manager.py index 96c496fd..429382bf 100644 --- a/backend/app/services/simulation_manager.py +++ b/backend/app/services/simulation_manager.py @@ -7,12 +7,14 @@ import os import json import shutil +import csv from typing import Dict, Any, List, Optional from dataclasses import dataclass, field from datetime import datetime from enum import Enum from ..config import Config +from ..i18n import get_locale, tr from ..utils.logger import get_logger from .zep_entity_reader import ZepEntityReader, FilteredEntities from .oasis_profile_generator import OasisProfileGenerator, OasisAgentProfile @@ -222,7 +224,15 @@ def create_simulation( ) self._save_simulation_state(state) - logger.info(f"创建模拟: {simulation_id}, project={project_id}, graph={graph_id}") + logger.info( + tr( + "simulation.created_log", + get_locale(), + simulation_id=simulation_id, + project_id=project_id, + graph_id=graph_id, + ) + ) return state @@ -234,7 +244,8 @@ def prepare_simulation( defined_entity_types: Optional[List[str]] = None, use_llm_for_profiles: bool = True, progress_callback: Optional[callable] = None, - parallel_profile_count: int = 3 + parallel_profile_count: int = 3, + locale: str = "zh", ) -> SimulationState: """ 准备模拟环境(全程自动化) @@ -260,7 +271,7 @@ def prepare_simulation( """ state = self._load_simulation_state(simulation_id) if not state: - raise ValueError(f"模拟不存在: {simulation_id}") + raise ValueError(tr("simulation.not_found", locale, simulation_id=simulation_id)) try: state.status = SimulationStatus.PREPARING @@ -270,12 +281,12 @@ def prepare_simulation( # ========== 阶段1: 读取并过滤实体 ========== if progress_callback: - progress_callback("reading", 0, "正在连接Zep图谱...") + progress_callback("reading", 0, tr("simulation.prepare_connecting_graph", locale)) reader = ZepEntityReader() if progress_callback: - progress_callback("reading", 30, "正在读取节点数据...") + progress_callback("reading", 30, tr("simulation.prepare_reading_nodes", locale)) filtered = reader.filter_defined_entities( graph_id=state.graph_id, @@ -289,14 +300,14 @@ def prepare_simulation( if progress_callback: progress_callback( "reading", 100, - f"完成,共 {filtered.filtered_count} 个实体", + tr("simulation.prepare_entities_completed", locale, count=filtered.filtered_count), current=filtered.filtered_count, total=filtered.filtered_count ) if filtered.filtered_count == 0: state.status = SimulationStatus.FAILED - state.error = "没有找到符合条件的实体,请检查图谱是否正确构建" + state.error = tr("simulation.no_matching_entities_build_graph", locale) self._save_simulation_state(state) return state @@ -306,13 +317,13 @@ def prepare_simulation( if progress_callback: progress_callback( "generating_profiles", 0, - "开始生成...", + tr("simulation.prepare_generation_starting", locale), current=0, total=total_entities ) # 传入graph_id以启用Zep检索功能,获取更丰富的上下文 - generator = OasisProfileGenerator(graph_id=state.graph_id) + generator = OasisProfileGenerator(graph_id=state.graph_id, locale=locale) def profile_progress(current, total, msg): if progress_callback: @@ -352,7 +363,7 @@ def profile_progress(current, total, msg): if progress_callback: progress_callback( "generating_profiles", 95, - "保存Profile文件...", + tr("simulation.prepare_saving_profiles", locale), current=total_entities, total=total_entities ) @@ -375,7 +386,7 @@ def profile_progress(current, total, msg): if progress_callback: progress_callback( "generating_profiles", 100, - f"完成,共 {len(profiles)} 个Profile", + tr("simulation.prepare_profiles_completed", locale, count=len(profiles)), current=len(profiles), total=len(profiles) ) @@ -384,20 +395,36 @@ def profile_progress(current, total, msg): if progress_callback: progress_callback( "generating_config", 0, - "正在分析模拟需求...", + tr("simulation.prepare_analyzing_requirement", locale), current=0, total=3 ) - config_generator = SimulationConfigGenerator() + config_generator = SimulationConfigGenerator(locale=locale) if progress_callback: progress_callback( "generating_config", 30, - "正在调用LLM生成配置...", + tr("simulation.prepare_generating_config", locale), current=1, total=3 ) + + def config_progress(current: int, total: int, message: str) -> None: + if not progress_callback: + return + + safe_total = max(total, 1) + bounded_current = min(max(current, 0), safe_total) + scaled_progress = 30 + int((bounded_current / safe_total) * 35) + progress_callback( + "generating_config", + scaled_progress, + message, + current=bounded_current, + total=safe_total, + item_name=message, + ) sim_params = config_generator.generate_config( simulation_id=simulation_id, @@ -407,13 +434,14 @@ def profile_progress(current, total, msg): document_text=document_text, entities=filtered.entities, enable_twitter=state.enable_twitter, - enable_reddit=state.enable_reddit + enable_reddit=state.enable_reddit, + progress_callback=config_progress, ) if progress_callback: progress_callback( "generating_config", 70, - "正在保存配置文件...", + tr("simulation.prepare_saving_config", locale), current=2, total=3 ) @@ -429,7 +457,7 @@ def profile_progress(current, total, msg): if progress_callback: progress_callback( "generating_config", 100, - "配置生成完成", + tr("simulation.prepare_config_completed", locale), current=3, total=3 ) @@ -441,13 +469,27 @@ def profile_progress(current, total, msg): state.status = SimulationStatus.READY self._save_simulation_state(state) - logger.info(f"模拟准备完成: {simulation_id}, " - f"entities={state.entities_count}, profiles={state.profiles_count}") + logger.info( + tr( + "simulation.prepare_completed_log", + locale, + simulation_id=simulation_id, + entities=state.entities_count, + profiles=state.profiles_count, + ) + ) return state except Exception as e: - logger.error(f"模拟准备失败: {simulation_id}, error={str(e)}") + logger.error( + tr( + "simulation.prepare_failed_log", + locale, + simulation_id=simulation_id, + error=str(e), + ) + ) import traceback logger.error(traceback.format_exc()) state.status = SimulationStatus.FAILED @@ -458,6 +500,57 @@ def profile_progress(current, total, msg): def get_simulation(self, simulation_id: str) -> Optional[SimulationState]: """获取模拟状态""" return self._load_simulation_state(simulation_id) + + def get_enabled_platforms(self, simulation_id: str) -> List[str]: + """Return enabled platforms in stable preference order.""" + state = self._load_simulation_state(simulation_id) + if not state: + raise ValueError(tr("simulation.not_found", get_locale(), simulation_id=simulation_id)) + + platforms: List[str] = [] + if state.enable_reddit: + platforms.append(PlatformType.REDDIT.value) + if state.enable_twitter: + platforms.append(PlatformType.TWITTER.value) + return platforms + + def resolve_platform(self, simulation_id: str, platform: Optional[str] = None) -> str: + """ + Resolve a caller-requested platform against the simulation's enabled platforms. + + If exactly one platform is enabled, use it even when older callers still request + the historical reddit default. + """ + normalized = platform.strip().lower() if isinstance(platform, str) else None + if normalized == "": + normalized = None + + valid_platforms = {PlatformType.REDDIT.value, PlatformType.TWITTER.value} + if normalized and normalized not in valid_platforms: + raise ValueError(tr("simulation.platform_invalid", get_locale())) + + enabled_platforms = self.get_enabled_platforms(simulation_id) + if normalized in enabled_platforms: + return normalized + + if len(enabled_platforms) == 1: + if normalized and normalized != enabled_platforms[0]: + logger.info( + "simulation %s requested disabled platform %s; falling back to enabled platform %s", + simulation_id, + normalized, + enabled_platforms[0], + ) + return enabled_platforms[0] + + if normalized: + return normalized + + if enabled_platforms: + return enabled_platforms[0] + + # Legacy fallback for malformed historical state files with no enabled flags. + return PlatformType.REDDIT.value def list_simulations(self, project_id: Optional[str] = None) -> List[SimulationState]: """列出所有模拟""" @@ -476,20 +569,35 @@ def list_simulations(self, project_id: Optional[str] = None) -> List[SimulationS simulations.append(state) return simulations + + def delete_simulation(self, simulation_id: str) -> bool: + """删除模拟目录及其持久化状态。""" + sim_dir = os.path.join(self.SIMULATION_DATA_DIR, simulation_id) + + self._simulations.pop(simulation_id, None) + + if not os.path.exists(sim_dir): + return False + + shutil.rmtree(sim_dir) + return True def get_profiles(self, simulation_id: str, platform: str = "reddit") -> List[Dict[str, Any]]: """获取模拟的Agent Profile""" - state = self._load_simulation_state(simulation_id) - if not state: - raise ValueError(f"模拟不存在: {simulation_id}") + platform = self.resolve_platform(simulation_id, platform) sim_dir = self._get_simulation_dir(simulation_id) - profile_path = os.path.join(sim_dir, f"{platform}_profiles.json") + profile_path = os.path.join( + sim_dir, + "twitter_profiles.csv" if platform == PlatformType.TWITTER.value else "reddit_profiles.json", + ) if not os.path.exists(profile_path): return [] with open(profile_path, 'r', encoding='utf-8') as f: + if platform == PlatformType.TWITTER.value: + return list(csv.DictReader(f)) return json.load(f) def get_simulation_config(self, simulation_id: str) -> Optional[Dict[str, Any]]: @@ -503,12 +611,12 @@ def get_simulation_config(self, simulation_id: str) -> Optional[Dict[str, Any]]: with open(config_path, 'r', encoding='utf-8') as f: return json.load(f) - def get_run_instructions(self, simulation_id: str) -> Dict[str, str]: + def get_run_instructions(self, simulation_id: str, locale: str | None = None) -> Dict[str, str]: """获取运行说明""" sim_dir = self._get_simulation_dir(simulation_id) config_path = os.path.join(sim_dir, "simulation_config.json") scripts_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../scripts')) - + return { "simulation_dir": sim_dir, "scripts_dir": scripts_dir, @@ -518,11 +626,32 @@ def get_run_instructions(self, simulation_id: str) -> Dict[str, str]: "reddit": f"python {scripts_dir}/run_reddit_simulation.py --config {config_path}", "parallel": f"python {scripts_dir}/run_parallel_simulation.py --config {config_path}", }, - "instructions": ( - f"1. 激活conda环境: conda activate MiroFish\n" - f"2. 运行模拟 (脚本位于 {scripts_dir}):\n" - f" - 单独运行Twitter: python {scripts_dir}/run_twitter_simulation.py --config {config_path}\n" - f" - 单独运行Reddit: python {scripts_dir}/run_reddit_simulation.py --config {config_path}\n" - f" - 并行运行双平台: python {scripts_dir}/run_parallel_simulation.py --config {config_path}" - ) + "instructions": "\n".join( + [ + tr("simulation.run_instructions_activate_env", locale), + tr( + "simulation.run_instructions_run_header", + locale, + scripts_dir=scripts_dir, + ), + tr( + "simulation.run_instructions_twitter", + locale, + scripts_dir=scripts_dir, + config_path=config_path, + ), + tr( + "simulation.run_instructions_reddit", + locale, + scripts_dir=scripts_dir, + config_path=config_path, + ), + tr( + "simulation.run_instructions_parallel", + locale, + scripts_dir=scripts_dir, + config_path=config_path, + ), + ] + ), } diff --git a/backend/app/services/simulation_runner.py b/backend/app/services/simulation_runner.py index 8c35380d..67154c94 100644 --- a/backend/app/services/simulation_runner.py +++ b/backend/app/services/simulation_runner.py @@ -12,6 +12,8 @@ import subprocess import signal import atexit +import importlib.util +from collections import deque from typing import Dict, Any, List, Optional, Union from dataclasses import dataclass, field from datetime import datetime @@ -19,12 +21,20 @@ from queue import Queue from ..config import Config +from ..i18n import get_locale, tr from ..utils.logger import get_logger from .zep_graph_memory_updater import ZepGraphMemoryManager from .simulation_ipc import SimulationIPCClient, CommandType, IPCResponse logger = get_logger('mirofish.simulation_runner') + +def simulation_dependency_error(locale: str | None = None) -> str: + return tr("simulation.runner_dependency_error", locale) + + +SIMULATION_DEPENDENCY_ERROR = simulation_dependency_error() + # 标记是否已注册清理函数 _cleanup_registered = False @@ -101,6 +111,7 @@ def to_dict(self) -> Dict[str, Any]: class SimulationRunState: """模拟运行状态(实时)""" simulation_id: str + locale: str = "zh" runner_status: RunnerStatus = RunnerStatus.IDLE # 进度信息 @@ -159,6 +170,7 @@ def add_action(self, action: AgentAction): def to_dict(self) -> Dict[str, Any]: return { "simulation_id": self.simulation_id, + "locale": self.locale, "runner_status": self.runner_status.value, "current_round": self.current_round, "total_rounds": self.total_rounds, @@ -225,17 +237,134 @@ class SimulationRunner: # 图谱记忆更新配置 _graph_memory_enabled: Dict[str, bool] = {} # simulation_id -> enabled + + @staticmethod + def _log_translated( + level: str, + key: str, + locale: str | None = None, + **params, + ) -> None: + getattr(logger, level)(tr(key, locale, **params)) + + @classmethod + def _log_for_simulation( + cls, + level: str, + key: str, + simulation_id: str, + locale: str | None = None, + **params, + ) -> None: + resolved_locale = cls._resolve_locale_for_simulation(simulation_id, locale) + cls._log_translated( + level, + key, + resolved_locale, + simulation_id=simulation_id, + **params, + ) + + @staticmethod + def _format_process_exit_error(exit_code: int, details: str, locale: str | None = None) -> str: + normalized = (details or "").lower() + huggingface_markers = ( + "huggingface.co", + "huggingface_hub", + "sentence-transformers", + "transformers", + "hf_hub_download", + ) + network_markers = ( + "connection error", + "connection aborted", + "connection reset", + "temporary failure in name resolution", + "name or service not known", + "failed to establish a new connection", + "max retries exceeded", + "proxyerror", + "connecttimeout", + "readtimeout", + "ssl", + "certificate verify failed", + "network is unreachable", + ) + + if any(marker in normalized for marker in huggingface_markers) and any( + marker in normalized for marker in network_markers + ): + return tr("simulation.process_exit_huggingface_network", locale) + + return tr( + "simulation.process_exit", + locale, + exit_code=exit_code, + details=details, + ) @classmethod def get_run_state(cls, simulation_id: str) -> Optional[SimulationRunState]: """获取运行状态""" if simulation_id in cls._run_states: - return cls._run_states[simulation_id] - + return cls._reconcile_run_state(cls._run_states[simulation_id]) + # 尝试从文件加载 state = cls._load_run_state(simulation_id) if state: cls._run_states[simulation_id] = state + return cls._reconcile_run_state(state) + return state + + @classmethod + def _reconcile_run_state(cls, state: SimulationRunState) -> SimulationRunState: + """Normalize persisted running states when the worker process has already exited.""" + if state.runner_status not in { + RunnerStatus.RUNNING, + RunnerStatus.STARTING, + RunnerStatus.PAUSED, + }: + return state + + process = cls._processes.get(state.simulation_id) + if process is not None: + returncode = process.poll() + if returncode is None: + return state + return cls._finalize_exited_run_state(state, returncode=returncode) + + if cls._process_pid_is_alive(state.process_pid): + return state + + return cls._finalize_exited_run_state(state, returncode=None) + + @classmethod + def _finalize_exited_run_state( + cls, + state: SimulationRunState, + returncode: int | None, + ) -> SimulationRunState: + now = datetime.now().isoformat() + state.completed_at = state.completed_at or now + state.updated_at = now + state.twitter_running = False + state.reddit_running = False + + if returncode == 0: + state.runner_status = RunnerStatus.COMPLETED + state.error = None + elif isinstance(returncode, int): + state.runner_status = RunnerStatus.FAILED + state.error = cls._format_process_exit_error( + exit_code=returncode, + details=cls.get_simulation_log_tail(state.simulation_id), + locale=state.locale, + ) + else: + state.runner_status = RunnerStatus.STOPPED + state.error = state.error or tr("simulation.environment_not_alive", state.locale) + + cls._save_run_state(state) return state @classmethod @@ -251,6 +380,7 @@ def _load_run_state(cls, simulation_id: str) -> Optional[SimulationRunState]: state = SimulationRunState( simulation_id=simulation_id, + locale=data.get("locale", "zh"), runner_status=RunnerStatus(data.get("runner_status", "idle")), current_round=data.get("current_round", 0), total_rounds=data.get("total_rounds", 0), @@ -291,8 +421,28 @@ def _load_run_state(cls, simulation_id: str) -> Optional[SimulationRunState]: return state except Exception as e: - logger.error(f"加载运行状态失败: {str(e)}") + logger.error(tr("simulation.run_state_load_failed", get_locale(), error=str(e))) return None + + @classmethod + def _resolve_locale_for_simulation( + cls, + simulation_id: str, + locale: str | None = None, + ) -> str: + if locale in {"zh", "en"}: + return locale + state = cls.get_run_state(simulation_id) + if state and state.locale in {"zh", "en"}: + return state.locale + return get_locale(locale) + + @staticmethod + def _simulation_dependencies_available() -> bool: + return ( + importlib.util.find_spec("camel") is not None + and importlib.util.find_spec("oasis") is not None + ) @classmethod def _save_run_state(cls, state: SimulationRunState): @@ -315,7 +465,8 @@ def start_simulation( platform: str = "parallel", # twitter / reddit / parallel max_rounds: int = None, # 最大模拟轮数(可选,用于截断过长的模拟) enable_graph_memory_update: bool = False, # 是否将活动更新到Zep图谱 - graph_id: str = None # Zep图谱ID(启用图谱更新时必需) + graph_id: str = None, # Zep图谱ID(启用图谱更新时必需) + locale: str | None = None, ) -> SimulationRunState: """ 启动模拟 @@ -330,17 +481,21 @@ def start_simulation( Returns: SimulationRunState """ + resolved_locale = get_locale(locale) + # 检查是否已在运行 existing = cls.get_run_state(simulation_id) if existing and existing.runner_status in [RunnerStatus.RUNNING, RunnerStatus.STARTING]: - raise ValueError(f"模拟已在运行中: {simulation_id}") + raise ValueError( + tr("simulation.already_running", resolved_locale, simulation_id=simulation_id) + ) # 加载模拟配置 sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id) config_path = os.path.join(sim_dir, "simulation_config.json") if not os.path.exists(config_path): - raise ValueError(f"模拟配置不存在,请先调用 /prepare 接口") + raise ValueError(tr("simulation.start_config_missing", resolved_locale)) with open(config_path, 'r', encoding='utf-8') as f: config = json.load(f) @@ -356,10 +511,18 @@ def start_simulation( original_rounds = total_rounds total_rounds = min(total_rounds, max_rounds) if total_rounds < original_rounds: - logger.info(f"轮数已截断: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})") + cls._log_translated( + "info", + "simulation.rounds_truncated", + resolved_locale, + original_rounds=original_rounds, + total_rounds=total_rounds, + max_rounds=max_rounds, + ) state = SimulationRunState( simulation_id=simulation_id, + locale=resolved_locale, runner_status=RunnerStatus.STARTING, total_rounds=total_rounds, total_simulation_hours=total_hours, @@ -371,14 +534,26 @@ def start_simulation( # 如果启用图谱记忆更新,创建更新器 if enable_graph_memory_update: if not graph_id: - raise ValueError("启用图谱记忆更新时必须提供 graph_id") + raise ValueError(tr("simulation.graph_id_required_for_memory", resolved_locale)) try: - ZepGraphMemoryManager.create_updater(simulation_id, graph_id) + ZepGraphMemoryManager.create_updater(simulation_id, graph_id, locale=resolved_locale) cls._graph_memory_enabled[simulation_id] = True - logger.info(f"已启用图谱记忆更新: simulation_id={simulation_id}, graph_id={graph_id}") + cls._log_for_simulation( + "info", + "simulation.graph_memory_enabled", + simulation_id, + resolved_locale, + graph_id=graph_id, + ) except Exception as e: - logger.error(f"创建图谱记忆更新器失败: {e}") + cls._log_for_simulation( + "error", + "simulation.graph_memory_enable_failed", + simulation_id, + resolved_locale, + details=str(e), + ) cls._graph_memory_enabled[simulation_id] = False else: cls._graph_memory_enabled[simulation_id] = False @@ -396,10 +571,14 @@ def start_simulation( state.reddit_running = True script_path = os.path.join(cls.SCRIPTS_DIR, script_name) - + if not os.path.exists(script_path): - raise ValueError(f"脚本不存在: {script_path}") - + raise ValueError( + tr("simulation.script_path_missing", resolved_locale, script_path=script_path) + ) + if not cls._simulation_dependencies_available(): + raise RuntimeError(simulation_dependency_error(resolved_locale)) + # 创建动作队列 action_queue = Queue() cls._action_queues[simulation_id] = action_queue @@ -431,6 +610,7 @@ def start_simulation( env = os.environ.copy() env['PYTHONUTF8'] = '1' # Python 3.7+ 支持,让所有 open() 默认使用 UTF-8 env['PYTHONIOENCODING'] = 'utf-8' # 确保 stdout/stderr 使用 UTF-8 + env['MIROFISH_LOCALE'] = resolved_locale # 设置工作目录为模拟目录(数据库等文件会生成在此) # 使用 start_new_session=True 创建新的进程组,确保可以通过 os.killpg 终止所有子进程 @@ -464,7 +644,14 @@ def start_simulation( monitor_thread.start() cls._monitor_threads[simulation_id] = monitor_thread - logger.info(f"模拟启动成功: {simulation_id}, pid={process.pid}, platform={platform}") + cls._log_for_simulation( + "info", + "simulation.started", + simulation_id, + resolved_locale, + pid=process.pid, + platform=platform, + ) except Exception as e: state.runner_status = RunnerStatus.FAILED @@ -522,7 +709,7 @@ def _monitor_simulation(cls, simulation_id: str): if exit_code == 0: state.runner_status = RunnerStatus.COMPLETED state.completed_at = datetime.now().isoformat() - logger.info(f"模拟完成: {simulation_id}") + cls._log_for_simulation("info", "simulation.completed", simulation_id, state.locale) else: state.runner_status = RunnerStatus.FAILED # 从主日志文件读取错误信息 @@ -534,15 +721,31 @@ def _monitor_simulation(cls, simulation_id: str): error_info = f.read()[-2000:] # 取最后2000字符 except Exception: pass - state.error = f"进程退出码: {exit_code}, 错误: {error_info}" - logger.error(f"模拟失败: {simulation_id}, error={state.error}") + state.error = cls._format_process_exit_error( + exit_code=exit_code, + details=error_info, + locale=state.locale, + ) + cls._log_for_simulation( + "error", + "simulation.failed", + simulation_id, + state.locale, + error=state.error, + ) state.twitter_running = False state.reddit_running = False cls._save_run_state(state) except Exception as e: - logger.error(f"监控线程异常: {simulation_id}, error={str(e)}") + cls._log_for_simulation( + "error", + "simulation.monitor_thread_failed", + simulation_id, + state.locale if state else None, + error=str(e), + ) state.runner_status = RunnerStatus.FAILED state.error = str(e) cls._save_run_state(state) @@ -552,9 +755,20 @@ def _monitor_simulation(cls, simulation_id: str): if cls._graph_memory_enabled.get(simulation_id, False): try: ZepGraphMemoryManager.stop_updater(simulation_id) - logger.info(f"已停止图谱记忆更新: simulation_id={simulation_id}") + cls._log_for_simulation( + "info", + "simulation.graph_memory_stopped", + simulation_id, + state.locale if state else None, + ) except Exception as e: - logger.error(f"停止图谱记忆更新器失败: {e}") + cls._log_for_simulation( + "error", + "simulation.graph_memory_stop_failed", + simulation_id, + state.locale if state else None, + details=str(e), + ) cls._graph_memory_enabled.pop(simulation_id, None) # 清理进程资源 @@ -619,11 +833,27 @@ def _read_action_log( if platform == "twitter": state.twitter_completed = True state.twitter_running = False - logger.info(f"Twitter 模拟已完成: {state.simulation_id}, total_rounds={action_data.get('total_rounds')}, total_actions={action_data.get('total_actions')}") + cls._log_for_simulation( + "info", + "simulation.platform_completed", + state.simulation_id, + state.locale, + platform=platform, + total_rounds=action_data.get("total_rounds"), + total_actions=action_data.get("total_actions"), + ) elif platform == "reddit": state.reddit_completed = True state.reddit_running = False - logger.info(f"Reddit 模拟已完成: {state.simulation_id}, total_rounds={action_data.get('total_rounds')}, total_actions={action_data.get('total_actions')}") + cls._log_for_simulation( + "info", + "simulation.platform_completed", + state.simulation_id, + state.locale, + platform=platform, + total_rounds=action_data.get("total_rounds"), + total_actions=action_data.get("total_actions"), + ) # 检查是否所有启用的平台都已完成 # 如果只运行了一个平台,只检查那个平台 @@ -632,7 +862,12 @@ def _read_action_log( if all_completed: state.runner_status = RunnerStatus.COMPLETED state.completed_at = datetime.now().isoformat() - logger.info(f"所有平台模拟已完成: {state.simulation_id}") + cls._log_for_simulation( + "info", + "simulation.all_platforms_completed", + state.simulation_id, + state.locale, + ) # 更新轮次信息(从 round_end 事件) elif event_type == "round_end": @@ -682,7 +917,13 @@ def _read_action_log( pass return f.tell() except Exception as e: - logger.warning(f"读取动作日志失败: {log_path}, error={e}") + cls._log_translated( + "warning", + "simulation.read_action_log_failed", + state.locale, + log_path=log_path, + error=str(e), + ) return position @classmethod @@ -725,7 +966,12 @@ def _terminate_process(cls, process: subprocess.Popen, simulation_id: str, timeo if IS_WINDOWS: # Windows: 使用 taskkill 命令终止进程树 # /F = 强制终止, /T = 终止进程树(包括子进程) - logger.info(f"终止进程树 (Windows): simulation={simulation_id}, pid={process.pid}") + cls._log_for_simulation( + "info", + "simulation.terminate_process_tree_windows", + simulation_id, + pid=process.pid, + ) try: # 先尝试优雅终止 subprocess.run( @@ -737,7 +983,11 @@ def _terminate_process(cls, process: subprocess.Popen, simulation_id: str, timeo process.wait(timeout=timeout) except subprocess.TimeoutExpired: # 强制终止 - logger.warning(f"进程未响应,强制终止: {simulation_id}") + cls._log_for_simulation( + "warning", + "simulation.process_force_kill", + simulation_id, + ) subprocess.run( ['taskkill', '/F', '/PID', str(process.pid), '/T'], capture_output=True, @@ -745,7 +995,12 @@ def _terminate_process(cls, process: subprocess.Popen, simulation_id: str, timeo ) process.wait(timeout=5) except Exception as e: - logger.warning(f"taskkill 失败,尝试 terminate: {e}") + cls._log_for_simulation( + "warning", + "simulation.taskkill_failed_fallback", + simulation_id, + details=str(e), + ) process.terminate() try: process.wait(timeout=5) @@ -755,7 +1010,12 @@ def _terminate_process(cls, process: subprocess.Popen, simulation_id: str, timeo # Unix: 使用进程组终止 # 由于使用了 start_new_session=True,进程组 ID 等于主进程 PID pgid = os.getpgid(process.pid) - logger.info(f"终止进程组 (Unix): simulation={simulation_id}, pgid={pgid}") + cls._log_for_simulation( + "info", + "simulation.terminate_process_group_unix", + simulation_id, + pgid=pgid, + ) # 先发送 SIGTERM 给整个进程组 os.killpg(pgid, signal.SIGTERM) @@ -764,19 +1024,31 @@ def _terminate_process(cls, process: subprocess.Popen, simulation_id: str, timeo process.wait(timeout=timeout) except subprocess.TimeoutExpired: # 如果超时后还没结束,强制发送 SIGKILL - logger.warning(f"进程组未响应 SIGTERM,强制终止: {simulation_id}") + cls._log_for_simulation( + "warning", + "simulation.process_group_force_kill", + simulation_id, + ) os.killpg(pgid, signal.SIGKILL) process.wait(timeout=5) @classmethod def stop_simulation(cls, simulation_id: str) -> SimulationRunState: """停止模拟""" + locale = get_locale() state = cls.get_run_state(simulation_id) if not state: - raise ValueError(f"模拟不存在: {simulation_id}") + raise ValueError(tr("simulation.not_found", locale, simulation_id=simulation_id)) if state.runner_status not in [RunnerStatus.RUNNING, RunnerStatus.PAUSED]: - raise ValueError(f"模拟未在运行: {simulation_id}, status={state.runner_status}") + raise ValueError( + tr( + "simulation.not_running_status", + locale, + simulation_id=simulation_id, + status=state.runner_status.value, + ) + ) state.runner_status = RunnerStatus.STOPPING cls._save_run_state(state) @@ -790,7 +1062,13 @@ def stop_simulation(cls, simulation_id: str) -> SimulationRunState: # 进程已经不存在 pass except Exception as e: - logger.error(f"终止进程组失败: {simulation_id}, error={e}") + cls._log_for_simulation( + "error", + "simulation.terminate_failed", + simulation_id, + state.locale, + error=str(e), + ) # 回退到直接终止进程 try: process.terminate() @@ -808,12 +1086,23 @@ def stop_simulation(cls, simulation_id: str) -> SimulationRunState: if cls._graph_memory_enabled.get(simulation_id, False): try: ZepGraphMemoryManager.stop_updater(simulation_id) - logger.info(f"已停止图谱记忆更新: simulation_id={simulation_id}") + cls._log_for_simulation( + "info", + "simulation.graph_memory_stopped", + simulation_id, + state.locale, + ) except Exception as e: - logger.error(f"停止图谱记忆更新器失败: {e}") + cls._log_for_simulation( + "error", + "simulation.graph_memory_stop_failed", + simulation_id, + state.locale, + details=str(e), + ) cls._graph_memory_enabled.pop(simulation_id, None) - - logger.info(f"模拟已停止: {simulation_id}") + + cls._log_for_simulation("info", "simulation.stopped", simulation_id, state.locale) return state @classmethod @@ -823,7 +1112,9 @@ def _read_actions_from_file( default_platform: Optional[str] = None, platform_filter: Optional[str] = None, agent_id: Optional[int] = None, - round_num: Optional[int] = None + round_num: Optional[int] = None, + since_timestamp: Optional[str] = None, + limit: Optional[int] = None, ) -> List[AgentAction]: """ 从单个动作文件中读取动作 @@ -838,7 +1129,10 @@ def _read_actions_from_file( if not os.path.exists(file_path): return [] - actions = [] + if limit is not None and limit > 0: + actions: Union[List[AgentAction], deque[AgentAction]] = deque(maxlen=limit) + else: + actions = [] with open(file_path, 'r', encoding='utf-8') as f: for line in f: @@ -867,10 +1161,13 @@ def _read_actions_from_file( continue if round_num is not None and data.get("round") != round_num: continue + timestamp = data.get("timestamp", "") + if since_timestamp and timestamp < since_timestamp: + continue actions.append(AgentAction( round_num=data.get("round", 0), - timestamp=data.get("timestamp", ""), + timestamp=timestamp, platform=record_platform, agent_id=data.get("agent_id", 0), agent_name=data.get("agent_name", ""), @@ -883,7 +1180,7 @@ def _read_actions_from_file( except json.JSONDecodeError: continue - return actions + return list(actions) @classmethod def get_all_actions( @@ -891,7 +1188,9 @@ def get_all_actions( simulation_id: str, platform: Optional[str] = None, agent_id: Optional[int] = None, - round_num: Optional[int] = None + round_num: Optional[int] = None, + since_timestamp: Optional[str] = None, + limit: Optional[int] = None, ) -> List[AgentAction]: """ 获取所有平台的完整动作历史(无分页限制) @@ -916,7 +1215,9 @@ def get_all_actions( default_platform="twitter", # 自动填充 platform 字段 platform_filter=platform, agent_id=agent_id, - round_num=round_num + round_num=round_num, + since_timestamp=since_timestamp, + limit=limit, )) # 读取 Reddit 动作文件(根据文件路径自动设置 platform 为 reddit) @@ -927,7 +1228,9 @@ def get_all_actions( default_platform="reddit", # 自动填充 platform 字段 platform_filter=platform, agent_id=agent_id, - round_num=round_num + round_num=round_num, + since_timestamp=since_timestamp, + limit=limit, )) # 如果分平台文件不存在,尝试读取旧的单一文件格式 @@ -938,13 +1241,48 @@ def get_all_actions( default_platform=None, # 旧格式文件中应该有 platform 字段 platform_filter=platform, agent_id=agent_id, - round_num=round_num + round_num=round_num, + since_timestamp=since_timestamp, + limit=limit, ) # 按时间戳排序(新的在前) actions.sort(key=lambda x: x.timestamp, reverse=True) + + if limit is not None and limit > 0: + actions = actions[:limit] return actions + + @classmethod + def get_simulation_log_tail( + cls, + simulation_id: str, + max_chars: int = 1200, + max_lines: int = 12, + ) -> str: + """Return a compact tail of simulation.log for diagnostics.""" + if max_chars <= 0 or max_lines <= 0: + return "" + + log_path = os.path.join(cls.RUN_STATE_DIR, simulation_id, "simulation.log") + if not os.path.exists(log_path): + return "" + + try: + with open(log_path, "r", encoding="utf-8") as handle: + handle.seek(0, os.SEEK_END) + file_size = handle.tell() + handle.seek(max(0, file_size - max_chars)) + tail = handle.read() + except OSError: + return "" + + lines = [line.rstrip() for line in tail.splitlines() if line.strip()] + if not lines: + return "" + + return "\n".join(lines[-max_lines:]) @classmethod def get_actions( @@ -1095,7 +1433,11 @@ def get_agent_stats(cls, simulation_id: str) -> List[Dict[str, Any]]: return result @classmethod - def cleanup_simulation_logs(cls, simulation_id: str) -> Dict[str, Any]: + def cleanup_simulation_logs( + cls, + simulation_id: str, + locale: str | None = None, + ) -> Dict[str, Any]: """ 清理模拟的运行日志(用于强制重新开始模拟) @@ -1117,12 +1459,14 @@ def cleanup_simulation_logs(cls, simulation_id: str) -> Dict[str, Any]: Returns: 清理结果信息 """ - import shutil - + resolved_locale = cls._resolve_locale_for_simulation(simulation_id, locale) sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id) if not os.path.exists(sim_dir): - return {"success": True, "message": "模拟目录不存在,无需清理"} + return { + "success": True, + "message": tr("simulation.cleanup_dir_missing", resolved_locale), + } cleaned_files = [] errors = [] @@ -1149,7 +1493,14 @@ def cleanup_simulation_logs(cls, simulation_id: str) -> Dict[str, Any]: os.remove(file_path) cleaned_files.append(filename) except Exception as e: - errors.append(f"删除 {filename} 失败: {str(e)}") + errors.append( + tr( + "simulation.cleanup_delete_failed", + resolved_locale, + target=filename, + details=str(e), + ) + ) # 清理平台目录中的动作日志 for dir_name in dirs_to_clean: @@ -1161,13 +1512,27 @@ def cleanup_simulation_logs(cls, simulation_id: str) -> Dict[str, Any]: os.remove(actions_file) cleaned_files.append(f"{dir_name}/actions.jsonl") except Exception as e: - errors.append(f"删除 {dir_name}/actions.jsonl 失败: {str(e)}") + errors.append( + tr( + "simulation.cleanup_delete_failed", + resolved_locale, + target=f"{dir_name}/actions.jsonl", + details=str(e), + ) + ) # 清理内存中的运行状态 if simulation_id in cls._run_states: del cls._run_states[simulation_id] - logger.info(f"清理模拟日志完成: {simulation_id}, 删除文件: {cleaned_files}") + logger.info( + tr( + "simulation.cleanup_completed", + resolved_locale, + simulation_id=simulation_id, + cleaned_files=cleaned_files, + ) + ) return { "success": len(errors) == 0, @@ -1196,14 +1561,28 @@ def cleanup_all_simulations(cls): if not has_processes and not has_updaters: return # 没有需要清理的内容,静默返回 - - logger.info("正在清理所有模拟进程...") + + cleanup_locale = next( + ( + state.locale + for state in cls._run_states.values() + if state.locale in {"zh", "en"} + ), + get_locale(), + ) + + cls._log_translated("info", "simulation.cleanup_all_started", cleanup_locale) # 首先停止所有图谱记忆更新器(stop_all 内部会打印日志) try: ZepGraphMemoryManager.stop_all() except Exception as e: - logger.error(f"停止图谱记忆更新器失败: {e}") + cls._log_translated( + "error", + "simulation.graph_memory_stop_all_failed", + cleanup_locale, + details=str(e), + ) cls._graph_memory_enabled.clear() # 复制字典以避免在迭代时修改 @@ -1212,7 +1591,12 @@ def cleanup_all_simulations(cls): for simulation_id, process in processes: try: if process.poll() is None: # 进程仍在运行 - logger.info(f"终止模拟进程: {simulation_id}, pid={process.pid}") + cls._log_for_simulation( + "info", + "simulation.terminating_process", + simulation_id, + pid=process.pid, + ) try: # 使用跨平台的进程终止方法 @@ -1227,19 +1611,26 @@ def cleanup_all_simulations(cls): # 更新 run_state.json state = cls.get_run_state(simulation_id) + state_locale = cls._resolve_locale_for_simulation(simulation_id) if state: state.runner_status = RunnerStatus.STOPPED state.twitter_running = False state.reddit_running = False state.completed_at = datetime.now().isoformat() - state.error = "服务器关闭,模拟被终止" + state.error = tr("simulation.stopped_server_shutdown", state_locale) cls._save_run_state(state) # 同时更新 state.json,将状态设为 stopped try: sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id) state_file = os.path.join(sim_dir, "state.json") - logger.info(f"尝试更新 state.json: {state_file}") + cls._log_for_simulation( + "info", + "simulation.state_json_update_attempt", + simulation_id, + state_locale, + state_file=state_file, + ) if os.path.exists(state_file): with open(state_file, 'r', encoding='utf-8') as f: state_data = json.load(f) @@ -1247,14 +1638,36 @@ def cleanup_all_simulations(cls): state_data['updated_at'] = datetime.now().isoformat() with open(state_file, 'w', encoding='utf-8') as f: json.dump(state_data, f, indent=2, ensure_ascii=False) - logger.info(f"已更新 state.json 状态为 stopped: {simulation_id}") + cls._log_for_simulation( + "info", + "simulation.state_json_updated", + simulation_id, + state_locale, + ) else: - logger.warning(f"state.json 不存在: {state_file}") + cls._log_for_simulation( + "warning", + "simulation.state_json_missing", + simulation_id, + state_locale, + state_file=state_file, + ) except Exception as state_err: - logger.warning(f"更新 state.json 失败: {simulation_id}, error={state_err}") + cls._log_for_simulation( + "warning", + "simulation.state_json_update_failed", + simulation_id, + state_locale, + error=str(state_err), + ) except Exception as e: - logger.error(f"清理进程失败: {simulation_id}, error={e}") + cls._log_for_simulation( + "error", + "simulation.cleanup_process_failed", + simulation_id, + error=str(e), + ) # 清理文件句柄 for simulation_id, file_handle in list(cls._stdout_files.items()): @@ -1277,7 +1690,7 @@ def cleanup_all_simulations(cls): cls._processes.clear() cls._action_queues.clear() - logger.info("模拟进程清理完成") + cls._log_translated("info", "simulation.cleanup_all_completed", cleanup_locale) @classmethod def register_cleanup(cls): @@ -1315,7 +1728,11 @@ def cleanup_handler(signum=None, frame=None): """信号处理器:先清理模拟进程,再调用原处理器""" # 只有在有进程需要清理时才打印日志 if cls._processes or cls._graph_memory_enabled: - logger.info(f"收到信号 {signum},开始清理...") + cls._log_translated( + "info", + "simulation.cleanup_signal_received", + signum=signum, + ) cls.cleanup_all_simulations() # 调用原有的信号处理器,让 Flask 正常退出 @@ -1348,7 +1765,7 @@ def cleanup_handler(signum=None, frame=None): signal.signal(signal.SIGHUP, cleanup_handler) except ValueError: # 不在主线程中,只能使用 atexit - logger.warning("无法注册信号处理器(不在主线程),仅使用 atexit") + cls._log_translated("warning", "simulation.signal_handler_register_failed") _cleanup_registered = True @@ -1362,6 +1779,37 @@ def get_running_simulations(cls) -> List[str]: if process.poll() is None: running.append(sim_id) return running + + @staticmethod + def _process_pid_is_alive(process_pid: int | None) -> bool: + """Best-effort PID liveness check for persisted simulation processes.""" + if not isinstance(process_pid, int) or process_pid <= 0: + return False + + try: + os.kill(process_pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + except OSError: + return False + + return True + + @classmethod + def _mark_env_status_stopped(cls, simulation_id: str): + sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id) + status_file = os.path.join(sim_dir, "env_status.json") + status = cls.get_env_status_detail(simulation_id) + status["status"] = "stopped" + status["timestamp"] = datetime.now().isoformat() + + try: + with open(status_file, 'w', encoding='utf-8') as f: + json.dump(status, f, ensure_ascii=False, indent=2) + except OSError: + pass # ============== Interview 功能 ============== @@ -1380,6 +1828,28 @@ def check_env_alive(cls, simulation_id: str) -> bool: if not os.path.exists(sim_dir): return False + state = cls.get_run_state(simulation_id) + if state and state.runner_status not in { + RunnerStatus.RUNNING, + RunnerStatus.STARTING, + RunnerStatus.PAUSED, + }: + cls._mark_env_status_stopped(simulation_id) + return False + + if state and state.process_pid and not cls._process_pid_is_alive(state.process_pid): + if state.runner_status in { + RunnerStatus.RUNNING, + RunnerStatus.STARTING, + RunnerStatus.PAUSED, + }: + state.runner_status = RunnerStatus.STOPPED + state.completed_at = state.completed_at or datetime.now().isoformat() + state.error = state.error or tr("simulation.environment_not_alive", state.locale) + cls._save_run_state(state) + cls._mark_env_status_stopped(simulation_id) + return False + ipc_client = SimulationIPCClient(sim_dir) return ipc_client.check_env_alive() @@ -1448,16 +1918,25 @@ def interview_agent( ValueError: 模拟不存在或环境未运行 TimeoutError: 等待响应超时 """ + locale = get_locale() sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id) if not os.path.exists(sim_dir): - raise ValueError(f"模拟不存在: {simulation_id}") + raise ValueError(tr("simulation.not_found", locale, simulation_id=simulation_id)) ipc_client = SimulationIPCClient(sim_dir) if not ipc_client.check_env_alive(): - raise ValueError(f"模拟环境未运行或已关闭,无法执行Interview: {simulation_id}") + raise ValueError(tr("simulation.environment_not_alive", locale)) - logger.info(f"发送Interview命令: simulation_id={simulation_id}, agent_id={agent_id}, platform={platform}") + logger.info( + tr( + "simulation.interview_command_sent", + locale, + simulation_id=simulation_id, + agent_id=agent_id, + platform=platform, + ) + ) response = ipc_client.send_interview( agent_id=agent_id, @@ -1510,16 +1989,25 @@ def interview_agents_batch( ValueError: 模拟不存在或环境未运行 TimeoutError: 等待响应超时 """ + locale = get_locale() sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id) if not os.path.exists(sim_dir): - raise ValueError(f"模拟不存在: {simulation_id}") + raise ValueError(tr("simulation.not_found", locale, simulation_id=simulation_id)) ipc_client = SimulationIPCClient(sim_dir) if not ipc_client.check_env_alive(): - raise ValueError(f"模拟环境未运行或已关闭,无法执行Interview: {simulation_id}") + raise ValueError(tr("simulation.environment_not_alive", locale)) - logger.info(f"发送批量Interview命令: simulation_id={simulation_id}, count={len(interviews)}, platform={platform}") + logger.info( + tr( + "simulation.batch_interview_command_sent", + locale, + simulation_id=simulation_id, + count=len(interviews), + platform=platform, + ) + ) response = ipc_client.send_batch_interview( interviews=interviews, @@ -1567,21 +2055,22 @@ def interview_all_agents( Returns: 全局采访结果字典 """ + locale = get_locale() sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id) if not os.path.exists(sim_dir): - raise ValueError(f"模拟不存在: {simulation_id}") + raise ValueError(tr("simulation.not_found", locale, simulation_id=simulation_id)) # 从配置文件获取所有Agent信息 config_path = os.path.join(sim_dir, "simulation_config.json") if not os.path.exists(config_path): - raise ValueError(f"模拟配置不存在: {simulation_id}") + raise ValueError(tr("simulation.config_missing_prepare", locale)) with open(config_path, 'r', encoding='utf-8') as f: config = json.load(f) agent_configs = config.get("agent_configs", []) if not agent_configs: - raise ValueError(f"模拟配置中没有Agent: {simulation_id}") + raise ValueError(tr("simulation.config_no_agents", locale, simulation_id=simulation_id)) # 构建批量采访列表 interviews = [] @@ -1593,7 +2082,15 @@ def interview_all_agents( "prompt": prompt }) - logger.info(f"发送全局Interview命令: simulation_id={simulation_id}, agent_count={len(interviews)}, platform={platform}") + logger.info( + tr( + "simulation.all_interview_command_sent", + locale, + simulation_id=simulation_id, + agent_count=len(interviews), + platform=platform, + ) + ) return cls.interview_agents_batch( simulation_id=simulation_id, @@ -1606,7 +2103,8 @@ def interview_all_agents( def close_simulation_env( cls, simulation_id: str, - timeout: float = 30.0 + timeout: float = 30.0, + locale: str | None = None, ) -> Dict[str, Any]: """ 关闭模拟环境(而不是停止模拟进程) @@ -1622,24 +2120,30 @@ def close_simulation_env( """ sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id) if not os.path.exists(sim_dir): - raise ValueError(f"模拟不存在: {simulation_id}") + raise ValueError(tr("simulation.not_found", locale, simulation_id=simulation_id)) ipc_client = SimulationIPCClient(sim_dir) if not ipc_client.check_env_alive(): return { "success": True, - "message": "环境已经关闭" + "message": tr("simulation.env_already_closed", locale) } - logger.info(f"发送关闭环境命令: simulation_id={simulation_id}") + logger.info( + tr( + "simulation.close_env_command_sent", + locale, + simulation_id=simulation_id, + ) + ) try: response = ipc_client.send_close_env(timeout=timeout) return { "success": response.status.value == "completed", - "message": "环境关闭命令已发送", + "message": tr("simulation.env_close_sent", locale), "result": response.result, "timestamp": response.timestamp } @@ -1647,12 +2151,13 @@ def close_simulation_env( # 超时可能是因为环境正在关闭 return { "success": True, - "message": "环境关闭命令已发送(等待响应超时,环境可能正在关闭)" + "message": tr("simulation.env_close_timeout", locale) } @classmethod def _get_interview_history_from_db( cls, + simulation_id: str, db_path: str, platform_name: str, agent_id: Optional[int] = None, @@ -1704,7 +2209,14 @@ def _get_interview_history_from_db( conn.close() except Exception as e: - logger.error(f"读取Interview历史失败 ({platform_name}): {e}") + logger.error( + tr( + "simulation.interview_history_read_failed", + cls._resolve_locale_for_simulation(simulation_id), + platform_name=platform_name, + error=str(e), + ) + ) return results @@ -1745,6 +2257,7 @@ def get_interview_history( for p in platforms: db_path = os.path.join(sim_dir, f"{p}_simulation.db") platform_results = cls._get_interview_history_from_db( + simulation_id=simulation_id, db_path=db_path, platform_name=p, agent_id=agent_id, @@ -1760,4 +2273,3 @@ def get_interview_history( results = results[:limit] return results - diff --git a/backend/app/services/text_processor.py b/backend/app/services/text_processor.py index 91e32acc..d84c7691 100644 --- a/backend/app/services/text_processor.py +++ b/backend/app/services/text_processor.py @@ -10,9 +10,9 @@ class TextProcessor: """文本处理器""" @staticmethod - def extract_from_files(file_paths: List[str]) -> str: + def extract_from_files(file_paths: List[str], locale: Optional[str] = None) -> str: """从多个文件提取文本""" - return FileParser.extract_from_multiple(file_paths) + return FileParser.extract_from_multiple(file_paths, locale=locale) @staticmethod def split_text( @@ -68,4 +68,3 @@ def get_text_stats(text: str) -> dict: "total_lines": text.count('\n') + 1, "total_words": len(text.split()), } - diff --git a/backend/app/services/zep_entity_reader.py b/backend/app/services/zep_entity_reader.py index 71661be4..76fdc805 100644 --- a/backend/app/services/zep_entity_reader.py +++ b/backend/app/services/zep_entity_reader.py @@ -3,13 +3,16 @@ 从Zep图谱中读取节点,筛选出符合预定义实体类型的节点 """ +import re import time +import unicodedata from typing import Dict, Any, List, Optional, Set, Callable, TypeVar from dataclasses import dataclass, field from zep_cloud.client import Zep from ..config import Config +from ..i18n import get_locale, tr from ..utils.logger import get_logger from ..utils.zep_paging import fetch_all_nodes, fetch_all_edges @@ -19,6 +22,77 @@ T = TypeVar('T') +def _fetch_with_optional_locale(fetcher: Callable[..., T], client: Zep, graph_id: str, locale: str) -> T: + try: + return fetcher(client, graph_id, locale=locale) + except TypeError as exc: + if "unexpected keyword argument 'locale'" not in str(exc): + raise + return fetcher(client, graph_id) + +_NON_WORD_RE = re.compile(r"[\W_]+", re.UNICODE) +_PERSON_PREFIXES = ( + "美国总统", + "总统", + "president", + "formerpresident", + "currentpresident", + "ceo", + "founder", + "cofounder", + "professor", + "doctor", + "dr", + "mr", + "mrs", + "ms", + "sir", +) +_ORG_SUFFIXES = ( + "有限责任公司", + "股份有限公司", + "有限公司", + "集团", + "公司", + "corporation", + "corp", + "inc", + "ltd", + "llc", + "university", +) +_PERSON_TYPE_HINTS = ( + "person", + "student", + "alumni", + "player", + "leader", + "figure", + "expert", + "human", + "人物", + "学生", + "校友", + "个人", + "公众人物", +) +_ORG_TYPE_HINTS = ( + "organization", + "company", + "institution", + "agency", + "university", + "media", + "group", + "企业", + "机构", + "组织", + "公司", + "媒体", + "大学", +) + + @dataclass class EntityNode: """实体节点数据结构""" @@ -78,12 +152,16 @@ class ZepEntityReader: 3. 获取每个实体的相关边和关联节点信息 """ - def __init__(self, api_key: Optional[str] = None): + def __init__(self, api_key: Optional[str] = None, locale: Optional[str] = None): self.api_key = api_key or Config.ZEP_API_KEY + self.locale = locale or get_locale() if not self.api_key: - raise ValueError("ZEP_API_KEY 未配置") - + raise ValueError(tr("config.key_missing", self._get_locale(), name="ZEP_API_KEY")) + self.client = Zep(api_key=self.api_key) + + def _get_locale(self) -> str: + return getattr(self, "locale", None) or get_locale() def _call_with_retry( self, @@ -114,13 +192,27 @@ def _call_with_retry( last_exception = e if attempt < max_retries - 1: logger.warning( - f"Zep {operation_name} 第 {attempt + 1} 次尝试失败: {str(e)[:100]}, " - f"{delay:.1f}秒后重试..." + tr( + "zep.reader_retry_failed_attempt", + self._get_locale(), + operation_name=operation_name, + attempt=attempt + 1, + error=str(e)[:100], + delay=delay, + ) ) time.sleep(delay) delay *= 2 # 指数退避 else: - logger.error(f"Zep {operation_name} 在 {max_retries} 次尝试后仍失败: {str(e)}") + logger.error( + tr( + "zep.reader_retry_failed_final", + self._get_locale(), + operation_name=operation_name, + max_retries=max_retries, + error=str(e), + ) + ) raise last_exception @@ -134,9 +226,9 @@ def get_all_nodes(self, graph_id: str) -> List[Dict[str, Any]]: Returns: 节点列表 """ - logger.info(f"获取图谱 {graph_id} 的所有节点...") + logger.info(tr("zep.reader_get_all_nodes_start", self._get_locale(), graph_id=graph_id)) - nodes = fetch_all_nodes(self.client, graph_id) + nodes = _fetch_with_optional_locale(fetch_all_nodes, self.client, graph_id, self._get_locale()) nodes_data = [] for node in nodes: @@ -148,7 +240,7 @@ def get_all_nodes(self, graph_id: str) -> List[Dict[str, Any]]: "attributes": node.attributes or {}, }) - logger.info(f"共获取 {len(nodes_data)} 个节点") + logger.info(tr("zep.reader_get_all_nodes_done", self._get_locale(), count=len(nodes_data))) return nodes_data def get_all_edges(self, graph_id: str) -> List[Dict[str, Any]]: @@ -161,9 +253,9 @@ def get_all_edges(self, graph_id: str) -> List[Dict[str, Any]]: Returns: 边列表 """ - logger.info(f"获取图谱 {graph_id} 的所有边...") + logger.info(tr("zep.reader_get_all_edges_start", self._get_locale(), graph_id=graph_id)) - edges = fetch_all_edges(self.client, graph_id) + edges = _fetch_with_optional_locale(fetch_all_edges, self.client, graph_id, self._get_locale()) edges_data = [] for edge in edges: @@ -176,7 +268,7 @@ def get_all_edges(self, graph_id: str) -> List[Dict[str, Any]]: "attributes": edge.attributes or {}, }) - logger.info(f"共获取 {len(edges_data)} 条边") + logger.info(tr("zep.reader_get_all_edges_done", self._get_locale(), count=len(edges_data))) return edges_data def get_node_edges(self, node_uuid: str) -> List[Dict[str, Any]]: @@ -193,7 +285,11 @@ def get_node_edges(self, node_uuid: str) -> List[Dict[str, Any]]: # 使用重试机制调用Zep API edges = self._call_with_retry( func=lambda: self.client.graph.node.get_entity_edges(node_uuid=node_uuid), - operation_name=f"获取节点边(node={node_uuid[:8]}...)" + operation_name=tr( + "zep.reader_get_node_edges_operation", + self._get_locale(), + node_uuid=f"{node_uuid[:8]}...", + ), ) edges_data = [] @@ -209,8 +305,193 @@ def get_node_edges(self, node_uuid: str) -> List[Dict[str, Any]]: return edges_data except Exception as e: - logger.warning(f"获取节点 {node_uuid} 的边失败: {str(e)}") + logger.warning( + tr( + "zep.reader_get_node_edges_failed", + self._get_locale(), + node_uuid=node_uuid, + error=str(e), + ) + ) return [] + + @staticmethod + def _normalize_entity_name(name: str) -> str: + normalized = unicodedata.normalize("NFKC", name or "").strip().lower() + return _NON_WORD_RE.sub("", normalized) + + @staticmethod + def _strip_known_affixes(normalized_name: str, entity_type: str) -> str: + entity_type_normalized = (entity_type or "").strip().lower() + stripped = normalized_name + + if any(hint in entity_type_normalized for hint in _PERSON_TYPE_HINTS): + for prefix in _PERSON_PREFIXES: + if stripped.startswith(prefix) and len(stripped) > len(prefix) + 1: + stripped = stripped[len(prefix):] + break + + if any(hint in entity_type_normalized for hint in _ORG_TYPE_HINTS): + for suffix in _ORG_SUFFIXES: + if stripped.endswith(suffix) and len(stripped) > len(suffix) + 1: + stripped = stripped[: -len(suffix)] + break + + return stripped or normalized_name + + @classmethod + def _entity_alias_key(cls, entity: EntityNode) -> str: + normalized_name = cls._normalize_entity_name(entity.name) + if not normalized_name: + return "" + entity_type = entity.get_entity_type() or "" + return cls._strip_known_affixes(normalized_name, entity_type) + + @classmethod + def _are_duplicate_entities(cls, left: EntityNode, right: EntityNode) -> bool: + left_type = left.get_entity_type() or "" + right_type = right.get_entity_type() or "" + if not left_type or left_type != right_type: + return False + + left_name = cls._normalize_entity_name(left.name) + right_name = cls._normalize_entity_name(right.name) + if not left_name or not right_name: + return False + + if left_name == right_name: + return True + + left_key = cls._entity_alias_key(left) + right_key = cls._entity_alias_key(right) + if not left_key or left_key != right_key or len(left_key) < 2: + return False + + shorter, longer = sorted((left_name, right_name), key=len) + return len(shorter) >= 2 and shorter in longer + + @staticmethod + def _merge_entity_lists(*entity_lists: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + merged: List[Dict[str, Any]] = [] + seen_keys: Set[tuple[str, str, str]] = set() + + for entities in entity_lists: + for item in entities: + if not isinstance(item, dict): + continue + key = ( + str(item.get("uuid", "")), + str(item.get("name", "")), + str(item.get("edge_name", item.get("labels", ""))), + ) + if key in seen_keys: + continue + seen_keys.add(key) + merged.append(item) + + return merged + + @staticmethod + def _deduplicate_related_edges(related_edges: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + deduplicated: List[Dict[str, Any]] = [] + seen_keys: Set[tuple[str, str, str, str]] = set() + + for edge in related_edges: + if not isinstance(edge, dict): + continue + + counterpart_uuid = str( + edge.get("target_node_uuid") + or edge.get("source_node_uuid") + or "" + ) + key = ( + str(edge.get("direction", "")), + str(edge.get("edge_name", "")), + str(edge.get("fact", "")), + counterpart_uuid, + ) + if key in seen_keys: + continue + seen_keys.add(key) + deduplicated.append(edge) + + return deduplicated + + @classmethod + def _deduplicate_related_nodes(cls, related_nodes: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + entities = [ + EntityNode( + uuid=str(node.get("uuid", "")), + name=str(node.get("name", "")), + labels=list(node.get("labels", []) or []), + summary=str(node.get("summary", "") or ""), + attributes={}, + ) + for node in related_nodes + if isinstance(node, dict) + ] + deduplicated_entities = cls._merge_duplicate_entities(entities) + return [ + { + "uuid": entity.uuid, + "name": entity.name, + "labels": entity.labels, + "summary": entity.summary, + } + for entity in deduplicated_entities + ] + + @classmethod + def _merge_duplicate_entities(cls, entities: List[EntityNode]) -> List[EntityNode]: + merged_entities: List[EntityNode] = [] + + for entity in entities: + duplicate_index = next( + (idx for idx, existing in enumerate(merged_entities) if cls._are_duplicate_entities(existing, entity)), + None, + ) + if duplicate_index is None: + merged_entities.append(entity) + continue + + existing = merged_entities[duplicate_index] + existing_score = ( + len(existing.related_edges), + len(existing.related_nodes), + len(existing.summary or ""), + ) + candidate_score = ( + len(entity.related_edges), + len(entity.related_nodes), + len(entity.summary or ""), + ) + + primary = existing + secondary = entity + if candidate_score > existing_score: + primary = entity + secondary = existing + + summary_parts = [part for part in (primary.summary, secondary.summary) if part] + primary.summary = max(summary_parts, key=len) if summary_parts else "" + + merged_attributes = dict(secondary.attributes or {}) + merged_attributes.update(primary.attributes or {}) + primary.attributes = merged_attributes + primary.related_edges = cls._merge_entity_lists(primary.related_edges, secondary.related_edges) + primary.related_nodes = cls._merge_entity_lists(primary.related_nodes, secondary.related_nodes) + + if duplicate_index is not None: + merged_entities[duplicate_index] = primary + + logger.info( + "Collapsing duplicate entity aliases for simulation input: %s <-> %s", + existing.name, + entity.name, + ) + + return merged_entities def filter_defined_entities( self, @@ -233,7 +514,7 @@ def filter_defined_entities( Returns: FilteredEntities: 过滤后的实体集合 """ - logger.info(f"开始筛选图谱 {graph_id} 的实体...") + logger.info(tr("zep.reader_filter_start", self._get_locale(), graph_id=graph_id)) # 获取所有节点 all_nodes = self.get_all_nodes(graph_id) @@ -320,14 +601,26 @@ def filter_defined_entities( filtered_entities.append(entity) - logger.info(f"筛选完成: 总节点 {total_count}, 符合条件 {len(filtered_entities)}, " - f"实体类型: {entity_types_found}") - + deduplicated_entities = self._merge_duplicate_entities(filtered_entities) + deduped_count = len(filtered_entities) - len(deduplicated_entities) + if deduped_count: + logger.info(tr("zep.reader_filter_deduped", self._get_locale(), count=deduped_count)) + + logger.info( + tr( + "zep.reader_filter_done", + self._get_locale(), + total_count=total_count, + filtered_count=len(deduplicated_entities), + entity_types=entity_types_found, + ) + ) + return FilteredEntities( - entities=filtered_entities, + entities=deduplicated_entities, entity_types=entity_types_found, total_count=total_count, - filtered_count=len(filtered_entities), + filtered_count=len(deduplicated_entities), ) def get_entity_with_context( @@ -346,68 +639,104 @@ def get_entity_with_context( EntityNode或None """ try: + all_nodes = self.get_all_nodes(graph_id) + all_edges = self.get_all_edges(graph_id) + node_map = {n["uuid"]: n for n in all_nodes} + # 使用重试机制获取节点 node = self._call_with_retry( func=lambda: self.client.graph.node.get(uuid_=entity_uuid), - operation_name=f"获取节点详情(uuid={entity_uuid[:8]}...)" + operation_name=tr( + "zep.reader_get_node_detail_operation", + self._get_locale(), + entity_uuid=f"{entity_uuid[:8]}...", + ), ) if not node: return None - - # 获取节点的边 - edges = self.get_node_edges(entity_uuid) - - # 获取所有节点用于关联查找 - all_nodes = self.get_all_nodes(graph_id) - node_map = {n["uuid"]: n for n in all_nodes} - - # 处理相关边和节点 + + requested_node = EntityNode( + uuid=getattr(node, 'uuid_', None) or getattr(node, 'uuid', ''), + name=node.name or "", + labels=node.labels or [], + summary=node.summary or "", + attributes=node.attributes or {}, + ) + + alias_entities: List[EntityNode] = [] + for raw_node in all_nodes: + candidate = EntityNode( + uuid=raw_node["uuid"], + name=raw_node["name"], + labels=raw_node["labels"], + summary=raw_node.get("summary", ""), + attributes=raw_node.get("attributes", {}), + ) + if candidate.uuid == requested_node.uuid or self._are_duplicate_entities(requested_node, candidate): + alias_entities.append(candidate) + + if not alias_entities: + alias_entities.append(requested_node) + + merged_entity = self._merge_duplicate_entities(alias_entities)[0] + alias_uuids = {entity.uuid for entity in alias_entities} + related_edges = [] related_node_uuids = set() - - for edge in edges: - if edge["source_node_uuid"] == entity_uuid: + for edge in all_edges: + source_uuid = edge["source_node_uuid"] + target_uuid = edge["target_node_uuid"] + source_in_alias = source_uuid in alias_uuids + target_in_alias = target_uuid in alias_uuids + + if not source_in_alias and not target_in_alias: + continue + if source_in_alias and target_in_alias: + continue + + if source_in_alias: related_edges.append({ "direction": "outgoing", "edge_name": edge["name"], "fact": edge["fact"], - "target_node_uuid": edge["target_node_uuid"], + "target_node_uuid": target_uuid, }) - related_node_uuids.add(edge["target_node_uuid"]) + related_node_uuids.add(target_uuid) else: related_edges.append({ "direction": "incoming", "edge_name": edge["name"], "fact": edge["fact"], - "source_node_uuid": edge["source_node_uuid"], - }) - related_node_uuids.add(edge["source_node_uuid"]) - - # 获取关联节点信息 - related_nodes = [] - for related_uuid in related_node_uuids: - if related_uuid in node_map: - related_node = node_map[related_uuid] - related_nodes.append({ - "uuid": related_node["uuid"], - "name": related_node["name"], - "labels": related_node["labels"], - "summary": related_node.get("summary", ""), + "source_node_uuid": source_uuid, }) - - return EntityNode( - uuid=getattr(node, 'uuid_', None) or getattr(node, 'uuid', ''), - name=node.name or "", - labels=node.labels or [], - summary=node.summary or "", - attributes=node.attributes or {}, - related_edges=related_edges, - related_nodes=related_nodes, + related_node_uuids.add(source_uuid) + + related_nodes = self._deduplicate_related_nodes( + [ + { + "uuid": node_map[related_uuid]["uuid"], + "name": node_map[related_uuid]["name"], + "labels": node_map[related_uuid]["labels"], + "summary": node_map[related_uuid].get("summary", ""), + } + for related_uuid in related_node_uuids + if related_uuid in node_map and related_uuid not in alias_uuids + ] ) + merged_entity.related_edges = self._deduplicate_related_edges(related_edges) + merged_entity.related_nodes = related_nodes + return merged_entity except Exception as e: - logger.error(f"获取实体 {entity_uuid} 失败: {str(e)}") + logger.error( + tr( + "zep.reader_get_entity_failed", + self._get_locale(), + entity_uuid=entity_uuid, + error=str(e), + ) + ) return None def get_entities_by_type( @@ -433,5 +762,3 @@ def get_entities_by_type( enrich_with_edges=enrich_with_edges ) return result.entities - - diff --git a/backend/app/services/zep_graph_memory_updater.py b/backend/app/services/zep_graph_memory_updater.py index a8f3cecd..cdd5098d 100644 --- a/backend/app/services/zep_graph_memory_updater.py +++ b/backend/app/services/zep_graph_memory_updater.py @@ -15,6 +15,7 @@ from zep_cloud.client import Zep from ..config import Config +from ..i18n import get_locale, tr from ..utils.logger import get_logger logger = get_logger('mirofish.zep_graph_memory_updater') @@ -30,6 +31,7 @@ class AgentActivity: action_args: Dict[str, Any] round_num: int timestamp: str + locale: str = "zh" def to_episode_text(self) -> str: """ @@ -63,8 +65,8 @@ def to_episode_text(self) -> str: def _describe_create_post(self) -> str: content = self.action_args.get("content", "") if content: - return f"发布了一条帖子:「{content}」" - return "发布了一条帖子" + return self._format('created a post: "{content}"', "发布了一条帖子:「{content}」", content=content) + return self._message("created a post", "发布了一条帖子") def _describe_like_post(self) -> str: """点赞帖子 - 包含帖子原文和作者信息""" @@ -72,12 +74,17 @@ def _describe_like_post(self) -> str: post_author = self.action_args.get("post_author_name", "") if post_content and post_author: - return f"点赞了{post_author}的帖子:「{post_content}」" + return self._format( + 'liked {author}\'s post: "{content}"', + "点赞了{author}的帖子:「{content}」", + author=post_author, + content=post_content, + ) elif post_content: - return f"点赞了一条帖子:「{post_content}」" + return self._format('liked a post: "{content}"', "点赞了一条帖子:「{content}」", content=post_content) elif post_author: - return f"点赞了{post_author}的一条帖子" - return "点赞了一条帖子" + return self._format("liked one of {author}'s posts", "点赞了{author}的一条帖子", author=post_author) + return self._message("liked a post", "点赞了一条帖子") def _describe_dislike_post(self) -> str: """踩帖子 - 包含帖子原文和作者信息""" @@ -85,12 +92,17 @@ def _describe_dislike_post(self) -> str: post_author = self.action_args.get("post_author_name", "") if post_content and post_author: - return f"踩了{post_author}的帖子:「{post_content}」" + return self._format( + 'disliked {author}\'s post: "{content}"', + "踩了{author}的帖子:「{content}」", + author=post_author, + content=post_content, + ) elif post_content: - return f"踩了一条帖子:「{post_content}」" + return self._format('disliked a post: "{content}"', "踩了一条帖子:「{content}」", content=post_content) elif post_author: - return f"踩了{post_author}的一条帖子" - return "踩了一条帖子" + return self._format("disliked one of {author}'s posts", "踩了{author}的一条帖子", author=post_author) + return self._message("disliked a post", "踩了一条帖子") def _describe_repost(self) -> str: """转发帖子 - 包含原帖内容和作者信息""" @@ -98,12 +110,17 @@ def _describe_repost(self) -> str: original_author = self.action_args.get("original_author_name", "") if original_content and original_author: - return f"转发了{original_author}的帖子:「{original_content}」" + return self._format( + 'reposted {author}\'s post: "{content}"', + "转发了{author}的帖子:「{content}」", + author=original_author, + content=original_content, + ) elif original_content: - return f"转发了一条帖子:「{original_content}」" + return self._format('reposted a post: "{content}"', "转发了一条帖子:「{content}」", content=original_content) elif original_author: - return f"转发了{original_author}的一条帖子" - return "转发了一条帖子" + return self._format("reposted one of {author}'s posts", "转发了{author}的一条帖子", author=original_author) + return self._message("reposted a post", "转发了一条帖子") def _describe_quote_post(self) -> str: """引用帖子 - 包含原帖内容、作者信息和引用评论""" @@ -113,16 +130,24 @@ def _describe_quote_post(self) -> str: base = "" if original_content and original_author: - base = f"引用了{original_author}的帖子「{original_content}」" + base = self._format( + 'quoted {author}\'s post "{content}"', + "引用了{author}的帖子「{content}」", + author=original_author, + content=original_content, + ) elif original_content: - base = f"引用了一条帖子「{original_content}」" + base = self._format('quoted a post "{content}"', "引用了一条帖子「{content}」", content=original_content) elif original_author: - base = f"引用了{original_author}的一条帖子" + base = self._format("quoted one of {author}'s posts", "引用了{author}的一条帖子", author=original_author) else: - base = "引用了一条帖子" - + base = self._message("quoted a post", "引用了一条帖子") + if quote_content: - base += f",并评论道:「{quote_content}」" + if self.locale == "en": + base += f', adding: "{quote_content}"' + else: + base += f",并评论道:「{quote_content}」" return base def _describe_follow(self) -> str: @@ -130,8 +155,8 @@ def _describe_follow(self) -> str: target_user_name = self.action_args.get("target_user_name", "") if target_user_name: - return f"关注了用户「{target_user_name}」" - return "关注了一个用户" + return self._format('followed user "{name}"', "关注了用户「{name}」", name=target_user_name) + return self._message("followed a user", "关注了一个用户") def _describe_create_comment(self) -> str: """发表评论 - 包含评论内容和所评论的帖子信息""" @@ -141,13 +166,29 @@ def _describe_create_comment(self) -> str: if content: if post_content and post_author: - return f"在{post_author}的帖子「{post_content}」下评论道:「{content}」" + return self._format( + 'commented on {author}\'s post "{post}": "{content}"', + "在{author}的帖子「{post}」下评论道:「{content}」", + author=post_author, + post=post_content, + content=content, + ) elif post_content: - return f"在帖子「{post_content}」下评论道:「{content}」" + return self._format( + 'commented on a post "{post}": "{content}"', + "在帖子「{post}」下评论道:「{content}」", + post=post_content, + content=content, + ) elif post_author: - return f"在{post_author}的帖子下评论道:「{content}」" - return f"评论道:「{content}」" - return "发表了评论" + return self._format( + 'commented on {author}\'s post: "{content}"', + "在{author}的帖子下评论道:「{content}」", + author=post_author, + content=content, + ) + return self._format('commented: "{content}"', "评论道:「{content}」", content=content) + return self._message("left a comment", "发表了评论") def _describe_like_comment(self) -> str: """点赞评论 - 包含评论内容和作者信息""" @@ -155,12 +196,17 @@ def _describe_like_comment(self) -> str: comment_author = self.action_args.get("comment_author_name", "") if comment_content and comment_author: - return f"点赞了{comment_author}的评论:「{comment_content}」" + return self._format( + 'liked {author}\'s comment: "{content}"', + "点赞了{author}的评论:「{content}」", + author=comment_author, + content=comment_content, + ) elif comment_content: - return f"点赞了一条评论:「{comment_content}」" + return self._format('liked a comment: "{content}"', "点赞了一条评论:「{content}」", content=comment_content) elif comment_author: - return f"点赞了{comment_author}的一条评论" - return "点赞了一条评论" + return self._format("liked one of {author}'s comments", "点赞了{author}的一条评论", author=comment_author) + return self._message("liked a comment", "点赞了一条评论") def _describe_dislike_comment(self) -> str: """踩评论 - 包含评论内容和作者信息""" @@ -168,34 +214,49 @@ def _describe_dislike_comment(self) -> str: comment_author = self.action_args.get("comment_author_name", "") if comment_content and comment_author: - return f"踩了{comment_author}的评论:「{comment_content}」" + return self._format( + 'disliked {author}\'s comment: "{content}"', + "踩了{author}的评论:「{content}」", + author=comment_author, + content=comment_content, + ) elif comment_content: - return f"踩了一条评论:「{comment_content}」" + return self._format('disliked a comment: "{content}"', "踩了一条评论:「{content}」", content=comment_content) elif comment_author: - return f"踩了{comment_author}的一条评论" - return "踩了一条评论" + return self._format("disliked one of {author}'s comments", "踩了{author}的一条评论", author=comment_author) + return self._message("disliked a comment", "踩了一条评论") def _describe_search(self) -> str: """搜索帖子 - 包含搜索关键词""" query = self.action_args.get("query", "") or self.action_args.get("keyword", "") - return f"搜索了「{query}」" if query else "进行了搜索" + if query: + return self._format('searched for "{query}"', "搜索了「{query}」", query=query) + return self._message("performed a search", "进行了搜索") def _describe_search_user(self) -> str: """搜索用户 - 包含搜索关键词""" query = self.action_args.get("query", "") or self.action_args.get("username", "") - return f"搜索了用户「{query}」" if query else "搜索了用户" + if query: + return self._format('searched for user "{query}"', "搜索了用户「{query}」", query=query) + return self._message("searched for a user", "搜索了用户") def _describe_mute(self) -> str: """屏蔽用户 - 包含被屏蔽用户的名称""" target_user_name = self.action_args.get("target_user_name", "") if target_user_name: - return f"屏蔽了用户「{target_user_name}」" - return "屏蔽了一个用户" + return self._format('muted user "{name}"', "屏蔽了用户「{name}」", name=target_user_name) + return self._message("muted a user", "屏蔽了一个用户") def _describe_generic(self) -> str: # 对于未知的动作类型,生成通用描述 - return f"执行了{self.action_type}操作" + return self._format("performed action {action}", "执行了{action}操作", action=self.action_type) + + def _message(self, en: str, zh: str) -> str: + return en if self.locale == "en" else zh + + def _format(self, en: str, zh: str, **params: Any) -> str: + return self._message(en, zh).format(**params) class ZepGraphMemoryUpdater: @@ -217,8 +278,8 @@ class ZepGraphMemoryUpdater: # 平台名称映射(用于控制台显示) PLATFORM_DISPLAY_NAMES = { - 'twitter': '世界1', - 'reddit': '世界2', + "zh": {"twitter": "世界1", "reddit": "世界2"}, + "en": {"twitter": "World 1", "reddit": "World 2"}, } # 发送间隔(秒),避免请求过快 @@ -227,8 +288,11 @@ class ZepGraphMemoryUpdater: # 重试配置 MAX_RETRIES = 3 RETRY_DELAY = 2 # 秒 + + def _text(self, en: str, zh: str) -> str: + return en if self.locale == "en" else zh - def __init__(self, graph_id: str, api_key: Optional[str] = None): + def __init__(self, graph_id: str, api_key: Optional[str] = None, locale: Optional[str] = None): """ 初始化更新器 @@ -236,11 +300,14 @@ def __init__(self, graph_id: str, api_key: Optional[str] = None): graph_id: Zep图谱ID api_key: Zep API Key(可选,默认从配置读取) """ + resolved_locale = locale if locale in {"zh", "en"} else get_locale() + self.graph_id = graph_id self.api_key = api_key or Config.ZEP_API_KEY + self.locale = resolved_locale if resolved_locale in {"zh", "en"} else "zh" if not self.api_key: - raise ValueError("ZEP_API_KEY未配置") + raise ValueError(tr("graph.zep_key_missing", self.locale)) self.client = Zep(api_key=self.api_key) @@ -265,11 +332,17 @@ def __init__(self, graph_id: str, api_key: Optional[str] = None): self._failed_count = 0 # 发送失败的批次数 self._skipped_count = 0 # 被过滤跳过的活动数(DO_NOTHING) - logger.info(f"ZepGraphMemoryUpdater 初始化完成: graph_id={graph_id}, batch_size={self.BATCH_SIZE}") + logger.info( + self._text( + f"ZepGraphMemoryUpdater initialized: graph_id={graph_id}, batch_size={self.BATCH_SIZE}", + f"ZepGraphMemoryUpdater 初始化完成: graph_id={graph_id}, batch_size={self.BATCH_SIZE}", + ) + ) def _get_platform_display_name(self, platform: str) -> str: """获取平台的显示名称""" - return self.PLATFORM_DISPLAY_NAMES.get(platform.lower(), platform) + localized_names = self.PLATFORM_DISPLAY_NAMES.get(self.locale, self.PLATFORM_DISPLAY_NAMES["zh"]) + return localized_names.get(platform.lower(), platform) def start(self): """启动后台工作线程""" @@ -283,7 +356,12 @@ def start(self): name=f"ZepMemoryUpdater-{self.graph_id[:8]}" ) self._worker_thread.start() - logger.info(f"ZepGraphMemoryUpdater 已启动: graph_id={self.graph_id}") + logger.info( + self._text( + f"ZepGraphMemoryUpdater started: graph_id={self.graph_id}", + f"ZepGraphMemoryUpdater 已启动: graph_id={self.graph_id}", + ) + ) def stop(self): """停止后台工作线程""" @@ -295,12 +373,22 @@ def stop(self): if self._worker_thread and self._worker_thread.is_alive(): self._worker_thread.join(timeout=10) - logger.info(f"ZepGraphMemoryUpdater 已停止: graph_id={self.graph_id}, " - f"total_activities={self._total_activities}, " - f"batches_sent={self._total_sent}, " - f"items_sent={self._total_items_sent}, " - f"failed={self._failed_count}, " - f"skipped={self._skipped_count}") + logger.info( + self._text( + f"ZepGraphMemoryUpdater stopped: graph_id={self.graph_id}, " + f"total_activities={self._total_activities}, " + f"batches_sent={self._total_sent}, " + f"items_sent={self._total_items_sent}, " + f"failed={self._failed_count}, " + f"skipped={self._skipped_count}", + f"ZepGraphMemoryUpdater 已停止: graph_id={self.graph_id}, " + f"total_activities={self._total_activities}, " + f"batches_sent={self._total_sent}, " + f"items_sent={self._total_items_sent}, " + f"failed={self._failed_count}, " + f"skipped={self._skipped_count}", + ) + ) def add_activity(self, activity: AgentActivity): """ @@ -330,7 +418,12 @@ def add_activity(self, activity: AgentActivity): self._activity_queue.put(activity) self._total_activities += 1 - logger.debug(f"添加活动到Zep队列: {activity.agent_name} - {activity.action_type}") + logger.debug( + self._text( + f"Queued Zep activity: {activity.agent_name} - {activity.action_type}", + f"添加活动到Zep队列: {activity.agent_name} - {activity.action_type}", + ) + ) def add_activity_from_dict(self, data: Dict[str, Any], platform: str): """ @@ -352,6 +445,7 @@ def add_activity_from_dict(self, data: Dict[str, Any], platform: str): action_args=data.get("action_args", {}), round_num=data.get("round", 0), timestamp=data.get("timestamp", datetime.now().isoformat()), + locale=self.locale, ) self.add_activity(activity) @@ -384,7 +478,12 @@ def _worker_loop(self): pass except Exception as e: - logger.error(f"工作循环异常: {e}") + logger.error( + self._text( + f"Worker loop error: {e}", + f"工作循环异常: {e}", + ) + ) time.sleep(1) def _send_batch_activities(self, activities: List[AgentActivity], platform: str): @@ -414,16 +513,36 @@ def _send_batch_activities(self, activities: List[AgentActivity], platform: str) self._total_sent += 1 self._total_items_sent += len(activities) display_name = self._get_platform_display_name(platform) - logger.info(f"成功批量发送 {len(activities)} 条{display_name}活动到图谱 {self.graph_id}") - logger.debug(f"批量内容预览: {combined_text[:200]}...") + logger.info( + self._text( + f"Sent {len(activities)} {display_name} activities to graph {self.graph_id}", + f"成功批量发送 {len(activities)} 条{display_name}活动到图谱 {self.graph_id}", + ) + ) + logger.debug( + self._text( + f"Batch content preview: {combined_text[:200]}...", + f"批量内容预览: {combined_text[:200]}...", + ) + ) return except Exception as e: if attempt < self.MAX_RETRIES - 1: - logger.warning(f"批量发送到Zep失败 (尝试 {attempt + 1}/{self.MAX_RETRIES}): {e}") + logger.warning( + self._text( + f"Failed to send a batch to Zep (attempt {attempt + 1}/{self.MAX_RETRIES}): {e}", + f"批量发送到Zep失败 (尝试 {attempt + 1}/{self.MAX_RETRIES}): {e}", + ) + ) time.sleep(self.RETRY_DELAY * (attempt + 1)) else: - logger.error(f"批量发送到Zep失败,已重试{self.MAX_RETRIES}次: {e}") + logger.error( + self._text( + f"Failed to send a batch to Zep after {self.MAX_RETRIES} retries: {e}", + f"批量发送到Zep失败,已重试{self.MAX_RETRIES}次: {e}", + ) + ) self._failed_count += 1 def _flush_remaining(self): @@ -445,7 +564,12 @@ def _flush_remaining(self): for platform, buffer in self._platform_buffers.items(): if buffer: display_name = self._get_platform_display_name(platform) - logger.info(f"发送{display_name}平台剩余的 {len(buffer)} 条活动") + logger.info( + self._text( + f"Flushing the remaining {len(buffer)} activities for {display_name}", + f"发送{display_name}平台剩余的 {len(buffer)} 条活动", + ) + ) self._send_batch_activities(buffer, platform) # 清空所有缓冲区 for platform in self._platform_buffers: @@ -479,9 +603,26 @@ class ZepGraphMemoryManager: _updaters: Dict[str, ZepGraphMemoryUpdater] = {} _lock = threading.Lock() + + @staticmethod + def _text(locale: str, en: str, zh: str) -> str: + return en if locale == "en" else zh + + @classmethod + def _stop_all_locale(cls) -> str: + for updater in cls._updaters.values(): + if getattr(updater, "locale", None) in {"zh", "en"}: + return updater.locale + fallback = get_locale() + return fallback if fallback in {"zh", "en"} else "zh" @classmethod - def create_updater(cls, simulation_id: str, graph_id: str) -> ZepGraphMemoryUpdater: + def create_updater( + cls, + simulation_id: str, + graph_id: str, + locale: Optional[str] = None, + ) -> ZepGraphMemoryUpdater: """ 为模拟创建图谱记忆更新器 @@ -497,11 +638,17 @@ def create_updater(cls, simulation_id: str, graph_id: str) -> ZepGraphMemoryUpda if simulation_id in cls._updaters: cls._updaters[simulation_id].stop() - updater = ZepGraphMemoryUpdater(graph_id) + updater = ZepGraphMemoryUpdater(graph_id, locale=locale) updater.start() cls._updaters[simulation_id] = updater - logger.info(f"创建图谱记忆更新器: simulation_id={simulation_id}, graph_id={graph_id}") + logger.info( + cls._text( + locale, + f"Created graph memory updater: simulation_id={simulation_id}, graph_id={graph_id}", + f"创建图谱记忆更新器: simulation_id={simulation_id}, graph_id={graph_id}", + ) + ) return updater @classmethod @@ -514,9 +661,16 @@ def stop_updater(cls, simulation_id: str): """停止并移除模拟的更新器""" with cls._lock: if simulation_id in cls._updaters: - cls._updaters[simulation_id].stop() + updater = cls._updaters[simulation_id] + updater.stop() del cls._updaters[simulation_id] - logger.info(f"已停止图谱记忆更新器: simulation_id={simulation_id}") + logger.info( + cls._text( + updater.locale, + f"Stopped graph memory updater: simulation_id={simulation_id}", + f"已停止图谱记忆更新器: simulation_id={simulation_id}", + ) + ) # 防止 stop_all 重复调用的标志 _stop_all_done = False @@ -528,6 +682,7 @@ def stop_all(cls): if cls._stop_all_done: return cls._stop_all_done = True + stop_locale = cls._stop_all_locale() with cls._lock: if cls._updaters: @@ -535,9 +690,21 @@ def stop_all(cls): try: updater.stop() except Exception as e: - logger.error(f"停止更新器失败: simulation_id={simulation_id}, error={e}") + logger.error( + cls._text( + updater.locale, + f"Failed to stop updater: simulation_id={simulation_id}, error={e}", + f"停止更新器失败: simulation_id={simulation_id}, error={e}", + ) + ) cls._updaters.clear() - logger.info("已停止所有图谱记忆更新器") + logger.info( + cls._text( + stop_locale, + "Stopped all graph memory updaters", + "已停止所有图谱记忆更新器", + ) + ) @classmethod def get_all_stats(cls) -> Dict[str, Dict[str, Any]]: diff --git a/backend/app/services/zep_tools.py b/backend/app/services/zep_tools.py index 384cf540..13d5c660 100644 --- a/backend/app/services/zep_tools.py +++ b/backend/app/services/zep_tools.py @@ -16,6 +16,8 @@ from zep_cloud.client import Zep from ..config import Config +from ..i18n import get_locale, tr +from .zep_entity_reader import EntityNode, ZepEntityReader from ..utils.logger import get_logger from ..utils.llm_client import LLMClient from ..utils.zep_paging import fetch_all_nodes, fetch_all_edges @@ -23,6 +25,19 @@ logger = get_logger('mirofish.zep_tools') +def _localized_text(locale: str, zh: str, en: str) -> str: + return en if locale == "en" else zh + + +def _fetch_with_optional_locale(fetcher, client, graph_id: str, locale: str): + try: + return fetcher(client, graph_id, locale=locale) + except TypeError as exc: + if "unexpected keyword argument 'locale'" not in str(exc): + raise + return fetcher(client, graph_id) + + @dataclass class SearchResult: """搜索结果""" @@ -31,6 +46,7 @@ class SearchResult: nodes: List[Dict[str, Any]] query: str total_count: int + locale: str = "zh" def to_dict(self) -> Dict[str, Any]: return { @@ -43,10 +59,19 @@ def to_dict(self) -> Dict[str, Any]: def to_text(self) -> str: """转换为文本格式,供LLM理解""" - text_parts = [f"搜索查询: {self.query}", f"找到 {self.total_count} 条相关信息"] - + text_parts = [ + f"{_localized_text(self.locale, '搜索查询', 'Search query')}: {self.query}", + ( + f"{_localized_text(self.locale, '找到', 'Found')} " + f"{self.total_count} " + f"{_localized_text(self.locale, '条相关信息', 'relevant items')}" + ), + ] + if self.facts: - text_parts.append("\n### 相关事实:") + text_parts.append( + f"\n### {_localized_text(self.locale, '相关事实', 'Relevant facts')}:" + ) for i, fact in enumerate(self.facts, 1): text_parts.append(f"{i}. {fact}") @@ -61,20 +86,40 @@ class NodeInfo: labels: List[str] summary: str attributes: Dict[str, Any] + alias_names: List[str] = field(default_factory=list) + locale: str = "zh" def to_dict(self) -> Dict[str, Any]: - return { + result = { "uuid": self.uuid, "name": self.name, "labels": self.labels, "summary": self.summary, "attributes": self.attributes } + normalized_aliases = list(dict.fromkeys([*(self.alias_names or []), self.name])) + if len(normalized_aliases) > 1: + result["alias_names"] = normalized_aliases + return result def to_text(self) -> str: """转换为文本格式""" - entity_type = next((l for l in self.labels if l not in ["Entity", "Node"]), "未知类型") - return f"实体: {self.name} (类型: {entity_type})\n摘要: {self.summary}" + entity_type = next( + (l for l in self.labels if l not in ["Entity", "Node"]), + _localized_text(self.locale, "未知类型", "Unknown type"), + ) + text = ( + f"{_localized_text(self.locale, '实体', 'Entity')}: {self.name} " + f"({_localized_text(self.locale, '类型', 'Type')}: {entity_type})\n" + f"{_localized_text(self.locale, '摘要', 'Summary')}: {self.summary}" + ) + normalized_aliases = [alias for alias in dict.fromkeys([*(self.alias_names or []), self.name]) if alias] + if len(normalized_aliases) > 1: + text += ( + f"\n{_localized_text(self.locale, '别名', 'Aliases')}: " + + ", ".join(normalized_aliases) + ) + return text @dataclass @@ -92,6 +137,7 @@ class EdgeInfo: valid_at: Optional[str] = None invalid_at: Optional[str] = None expired_at: Optional[str] = None + locale: str = "zh" def to_dict(self) -> Dict[str, Any]: return { @@ -112,14 +158,23 @@ def to_text(self, include_temporal: bool = False) -> str: """转换为文本格式""" source = self.source_node_name or self.source_node_uuid[:8] target = self.target_node_name or self.target_node_uuid[:8] - base_text = f"关系: {source} --[{self.name}]--> {target}\n事实: {self.fact}" - + base_text = ( + f"{_localized_text(self.locale, '关系', 'Relationship')}: " + f"{source} --[{self.name}]--> {target}\n" + f"{_localized_text(self.locale, '事实', 'Fact')}: {self.fact}" + ) + if include_temporal: - valid_at = self.valid_at or "未知" - invalid_at = self.invalid_at or "至今" - base_text += f"\n时效: {valid_at} - {invalid_at}" + valid_at = self.valid_at or _localized_text(self.locale, "未知", "Unknown") + invalid_at = self.invalid_at or _localized_text(self.locale, "至今", "present") + base_text += ( + f"\n{_localized_text(self.locale, '时效', 'Validity')}: " + f"{valid_at} - {invalid_at}" + ) if self.expired_at: - base_text += f" (已过期: {self.expired_at})" + base_text += ( + f" ({_localized_text(self.locale, '已过期', 'expired')}: {self.expired_at})" + ) return base_text @@ -153,6 +208,7 @@ class InsightForgeResult: total_facts: int = 0 total_entities: int = 0 total_relationships: int = 0 + locale: str = "zh" def to_dict(self) -> Dict[str, Any]: return { @@ -170,40 +226,72 @@ def to_dict(self) -> Dict[str, Any]: def to_text(self) -> str: """转换为详细的文本格式,供LLM理解""" text_parts = [ - f"## 未来预测深度分析", - f"分析问题: {self.query}", - f"预测场景: {self.simulation_requirement}", - f"\n### 预测数据统计", - f"- 相关预测事实: {self.total_facts}条", - f"- 涉及实体: {self.total_entities}个", - f"- 关系链: {self.total_relationships}条" + f"## {_localized_text(self.locale, '未来预测深度分析', 'Future prediction deep analysis')}", + f"{_localized_text(self.locale, '分析问题', 'Analysis question')}: {self.query}", + f"{_localized_text(self.locale, '预测场景', 'Prediction scenario')}: {self.simulation_requirement}", + f"\n### {_localized_text(self.locale, '预测数据统计', 'Prediction data summary')}", + ( + f"- {_localized_text(self.locale, '相关预测事实', 'Relevant predictive facts')}: " + f"{self.total_facts}{_localized_text(self.locale, '条', '')}" + ), + ( + f"- {_localized_text(self.locale, '涉及实体', 'Entities involved')}: " + f"{self.total_entities}{_localized_text(self.locale, '个', '')}" + ), + ( + f"- {_localized_text(self.locale, '关系链', 'Relationship chains')}: " + f"{self.total_relationships}{_localized_text(self.locale, '条', '')}" + ), ] # 子问题 if self.sub_queries: - text_parts.append(f"\n### 分析的子问题") + text_parts.append( + f"\n### {_localized_text(self.locale, '分析的子问题', 'Analysis sub-questions')}" + ) for i, sq in enumerate(self.sub_queries, 1): text_parts.append(f"{i}. {sq}") # 语义搜索结果 if self.semantic_facts: - text_parts.append(f"\n### 【关键事实】(请在报告中引用这些原文)") + text_parts.append( + "\n### " + + _localized_text( + self.locale, + "【关键事实】(请在报告中引用这些原文)", + "[Key facts] (quote these original statements in the report)", + ) + ) for i, fact in enumerate(self.semantic_facts, 1): text_parts.append(f"{i}. \"{fact}\"") # 实体洞察 if self.entity_insights: - text_parts.append(f"\n### 【核心实体】") + text_parts.append( + f"\n### {_localized_text(self.locale, '【核心实体】', '[Core entities]')}" + ) for entity in self.entity_insights: - text_parts.append(f"- **{entity.get('name', '未知')}** ({entity.get('type', '实体')})") + text_parts.append( + "- **" + f"{entity.get('name', _localized_text(self.locale, '未知', 'Unknown'))}" + f"** ({entity.get('type', _localized_text(self.locale, '实体', 'Entity'))})" + ) if entity.get('summary'): - text_parts.append(f" 摘要: \"{entity.get('summary')}\"") + text_parts.append( + f" {_localized_text(self.locale, '摘要', 'Summary')}: " + f"\"{entity.get('summary')}\"" + ) if entity.get('related_facts'): - text_parts.append(f" 相关事实: {len(entity.get('related_facts', []))}条") + text_parts.append( + f" {_localized_text(self.locale, '相关事实', 'Related facts')}: " + f"{len(entity.get('related_facts', []))}{_localized_text(self.locale, '条', '')}" + ) # 关系链 if self.relationship_chains: - text_parts.append(f"\n### 【关系链】") + text_parts.append( + f"\n### {_localized_text(self.locale, '【关系链】', '[Relationship chains]')}" + ) for chain in self.relationship_chains: text_parts.append(f"- {chain}") @@ -232,6 +320,7 @@ class PanoramaResult: total_edges: int = 0 active_count: int = 0 historical_count: int = 0 + locale: str = "zh" def to_dict(self) -> Dict[str, Any]: return { @@ -249,32 +338,57 @@ def to_dict(self) -> Dict[str, Any]: def to_text(self) -> str: """转换为文本格式(完整版本,不截断)""" text_parts = [ - f"## 广度搜索结果(未来全景视图)", - f"查询: {self.query}", - f"\n### 统计信息", - f"- 总节点数: {self.total_nodes}", - f"- 总边数: {self.total_edges}", - f"- 当前有效事实: {self.active_count}条", - f"- 历史/过期事实: {self.historical_count}条" + f"## {_localized_text(self.locale, '广度搜索结果(未来全景视图)', 'Panorama search results (future overview)')}", + f"{_localized_text(self.locale, '查询', 'Query')}: {self.query}", + f"\n### {_localized_text(self.locale, '统计信息', 'Statistics')}", + f"- {_localized_text(self.locale, '总节点数', 'Total nodes')}: {self.total_nodes}", + f"- {_localized_text(self.locale, '总边数', 'Total edges')}: {self.total_edges}", + ( + f"- {_localized_text(self.locale, '当前有效事实', 'Current active facts')}: " + f"{self.active_count}{_localized_text(self.locale, '条', '')}" + ), + ( + f"- {_localized_text(self.locale, '历史/过期事实', 'Historical/expired facts')}: " + f"{self.historical_count}{_localized_text(self.locale, '条', '')}" + ), ] # 当前有效的事实(完整输出,不截断) if self.active_facts: - text_parts.append(f"\n### 【当前有效事实】(模拟结果原文)") + text_parts.append( + "\n### " + + _localized_text( + self.locale, + "【当前有效事实】(模拟结果原文)", + "[Current active facts] (original simulation output)", + ) + ) for i, fact in enumerate(self.active_facts, 1): text_parts.append(f"{i}. \"{fact}\"") # 历史/过期事实(完整输出,不截断) if self.historical_facts: - text_parts.append(f"\n### 【历史/过期事实】(演变过程记录)") + text_parts.append( + "\n### " + + _localized_text( + self.locale, + "【历史/过期事实】(演变过程记录)", + "[Historical/expired facts] (change timeline record)", + ) + ) for i, fact in enumerate(self.historical_facts, 1): text_parts.append(f"{i}. \"{fact}\"") # 关键实体(完整输出,不截断) if self.all_nodes: - text_parts.append(f"\n### 【涉及实体】") + text_parts.append( + f"\n### {_localized_text(self.locale, '【涉及实体】', '[Entities involved]')}" + ) for node in self.all_nodes: - entity_type = next((l for l in node.labels if l not in ["Entity", "Node"]), "实体") + entity_type = next( + (l for l in node.labels if l not in ["Entity", "Node"]), + _localized_text(self.locale, "实体", "Entity"), + ) text_parts.append(f"- **{node.name}** ({entity_type})") return "\n".join(text_parts) @@ -289,6 +403,7 @@ class AgentInterview: question: str # 采访问题 response: str # 采访回答 key_quotes: List[str] = field(default_factory=list) # 关键引言 + locale: str = "zh" def to_dict(self) -> Dict[str, Any]: return { @@ -303,31 +418,40 @@ def to_dict(self) -> Dict[str, Any]: def to_text(self) -> str: text = f"**{self.agent_name}** ({self.agent_role})\n" # 显示完整的agent_bio,不截断 - text += f"_简介: {self.agent_bio}_\n\n" + text += f"_{_localized_text(self.locale, '简介', 'Bio')}: {self.agent_bio}_\n\n" text += f"**Q:** {self.question}\n\n" text += f"**A:** {self.response}\n" if self.key_quotes: - text += "\n**关键引言:**\n" + text += f"\n**{_localized_text(self.locale, '关键引言', 'Key quotes')}:**\n" for quote in self.key_quotes: # 清理各种引号 clean_quote = quote.replace('\u201c', '').replace('\u201d', '').replace('"', '') + clean_quote = clean_quote.replace("'", "").replace('\u2018', '').replace('\u2019', '') clean_quote = clean_quote.replace('\u300c', '').replace('\u300d', '') clean_quote = clean_quote.strip() # 去掉开头的标点 - while clean_quote and clean_quote[0] in ',,;;::、。!?\n\r\t ': + while clean_quote and clean_quote[0] in ',,;;::、。!?.!?\n\r\t ': clean_quote = clean_quote[1:] - # 过滤包含问题编号的垃圾内容(问题1-9) - skip = False - for d in '123456789': - if f'\u95ee\u9898{d}' in clean_quote: - skip = True - break - if skip: + # 过滤包含问题编号的垃圾内容(中英文) + if any(f'\u95ee\u9898{d}' in clean_quote for d in '123456789') or clean_quote.lower().startswith("question "): continue # 截断过长内容(按句号截断,而非硬截断) if len(clean_quote) > 150: - dot_pos = clean_quote.find('\u3002', 80) - if dot_pos > 0: + sentence_end_candidates = [ + pos + for pos in ( + clean_quote.find('\u3002', 80), + clean_quote.find('. ', 80), + clean_quote.find('! ', 80), + clean_quote.find('? ', 80), + clean_quote.find('.', 80), + clean_quote.find('!', 80), + clean_quote.find('?', 80), + ) + if pos > 0 + ] + if sentence_end_candidates: + dot_pos = min(sentence_end_candidates) clean_quote = clean_quote[:dot_pos + 1] else: clean_quote = clean_quote[:147] + "..." @@ -358,6 +482,7 @@ class InterviewResult: # 统计 total_agents: int = 0 interviewed_count: int = 0 + locale: str = "zh" def to_dict(self) -> Dict[str, Any]: return { @@ -374,25 +499,33 @@ def to_dict(self) -> Dict[str, Any]: def to_text(self) -> str: """转换为详细的文本格式,供LLM理解和报告引用""" text_parts = [ - "## 深度采访报告", - f"**采访主题:** {self.interview_topic}", - f"**采访人数:** {self.interviewed_count} / {self.total_agents} 位模拟Agent", - "\n### 采访对象选择理由", - self.selection_reasoning or "(自动选择)", + _localized_text(self.locale, "## 深度采访报告", "## In-Depth Interview Report"), + f"**{_localized_text(self.locale, '采访主题', 'Interview topic')}:** {self.interview_topic}", + ( + f"**{_localized_text(self.locale, '采访人数', 'Interviewed agents')}:** " + f"{self.interviewed_count} / {self.total_agents} " + f"{_localized_text(self.locale, '位模拟Agent', 'simulated agents')}" + ), + f"\n### {_localized_text(self.locale, '采访对象选择理由', 'Why these agents were selected')}", + self.selection_reasoning or _localized_text(self.locale, "(自动选择)", "(selected automatically)"), "\n---", - "\n### 采访实录", + f"\n### {_localized_text(self.locale, '采访实录', 'Interview transcripts')}", ] if self.interviews: for i, interview in enumerate(self.interviews, 1): - text_parts.append(f"\n#### 采访 #{i}: {interview.agent_name}") + text_parts.append( + f"\n#### {_localized_text(self.locale, '采访', 'Interview')} #{i}: {interview.agent_name}" + ) text_parts.append(interview.to_text()) text_parts.append("\n---") else: - text_parts.append("(无采访记录)\n\n---") + text_parts.append(f"{_localized_text(self.locale, '(无采访记录)', '(no interview records)')}\n\n---") - text_parts.append("\n### 采访摘要与核心观点") - text_parts.append(self.summary or "(无摘要)") + text_parts.append( + f"\n### {_localized_text(self.locale, '采访摘要与核心观点', 'Interview summary and key takeaways')}" + ) + text_parts.append(self.summary or _localized_text(self.locale, "(无摘要)", "(no summary)")) return "\n".join(text_parts) @@ -424,12 +557,28 @@ class ZepToolsService: def __init__(self, api_key: Optional[str] = None, llm_client: Optional[LLMClient] = None): self.api_key = api_key or Config.ZEP_API_KEY if not self.api_key: - raise ValueError("ZEP_API_KEY 未配置") + raise ValueError(tr("config.key_missing", get_locale(), name="ZEP_API_KEY")) self.client = Zep(api_key=self.api_key) # LLM客户端用于InsightForge生成子问题 self._llm_client = llm_client - logger.info("ZepToolsService 初始化完成") + self._log("info", "ZepToolsService 初始化完成", "ZepToolsService initialized") + + @staticmethod + def _locale() -> str: + return get_locale() + + @classmethod + def _text(cls, zh: str, en: str, locale: Optional[str] = None) -> str: + return _localized_text(locale or cls._locale(), zh, en) + + @classmethod + def _log(cls, level: str, zh: str, en: str, locale: Optional[str] = None) -> None: + getattr(logger, level)(cls._text(zh, en, locale)) + + @classmethod + def _unknown_profession(cls, locale: Optional[str] = None) -> str: + return cls._text("未知", "Unknown", locale) @property def llm(self) -> LLMClient: @@ -450,14 +599,19 @@ def _call_with_retry(self, func, operation_name: str, max_retries: int = None): except Exception as e: last_exception = e if attempt < max_retries - 1: - logger.warning( - f"Zep {operation_name} 第 {attempt + 1} 次尝试失败: {str(e)[:100]}, " - f"{delay:.1f}秒后重试..." + self._log( + "warning", + f"Zep {operation_name} 第 {attempt + 1} 次尝试失败: {str(e)[:100]}, {delay:.1f}秒后重试...", + f"Zep {operation_name} attempt {attempt + 1} failed: {str(e)[:100]}. Retrying in {delay:.1f}s...", ) time.sleep(delay) delay *= 2 else: - logger.error(f"Zep {operation_name} 在 {max_retries} 次尝试后仍失败: {str(e)}") + self._log( + "error", + f"Zep {operation_name} 在 {max_retries} 次尝试后仍失败: {str(e)}", + f"Zep {operation_name} still failed after {max_retries} attempts: {str(e)}", + ) raise last_exception @@ -483,7 +637,13 @@ def search_graph( Returns: SearchResult: 搜索结果 """ - logger.info(f"图谱搜索: graph_id={graph_id}, query={query[:50]}...") + locale = self._locale() + self._log( + "info", + f"图谱搜索: graph_id={graph_id}, query={query[:50]}...", + f"Graph search: graph_id={graph_id}, query={query[:50]}...", + locale, + ) # 尝试使用Zep Cloud Search API try: @@ -495,7 +655,11 @@ def search_graph( scope=scope, reranker="cross_encoder" ), - operation_name=f"图谱搜索(graph={graph_id})" + operation_name=self._text( + f"图谱搜索(graph={graph_id})", + f"graph search (graph={graph_id})", + locale, + ) ) facts = [] @@ -524,22 +688,38 @@ def search_graph( "labels": getattr(node, 'labels', []), "summary": getattr(node, 'summary', ''), }) - # 节点摘要也算作事实 - if hasattr(node, 'summary') and node.summary: - facts.append(f"[{node.name}]: {node.summary}") + nodes, edges, node_facts = self._deduplicate_search_payload( + nodes, + edges, + locale, + "search results", + ) + facts.extend(node_facts) + facts = self._unique_search_facts(facts) - logger.info(f"搜索完成: 找到 {len(facts)} 条相关事实") + self._log( + "info", + f"搜索完成: 找到 {len(facts)} 条相关事实", + f"Search completed: found {len(facts)} relevant facts", + locale, + ) return SearchResult( facts=facts, edges=edges, nodes=nodes, query=query, - total_count=len(facts) + total_count=len(facts), + locale=self._locale(), ) except Exception as e: - logger.warning(f"Zep Search API失败,降级为本地搜索: {str(e)}") + self._log( + "warning", + f"Zep Search API失败,降级为本地搜索: {str(e)}", + f"Zep Search API failed; falling back to local search: {str(e)}", + locale, + ) # 降级:使用本地关键词匹配搜索 return self._local_search(graph_id, query, limit, scope) @@ -564,11 +744,18 @@ def _local_search( Returns: SearchResult: 搜索结果 """ - logger.info(f"使用本地搜索: query={query[:30]}...") + locale = self._locale() + self._log( + "info", + f"使用本地搜索: query={query[:30]}...", + f"Using local search: query={query[:30]}...", + locale, + ) facts = [] edges_result = [] - nodes_result = [] + nodes_result: List[NodeInfo] = [] + node_dicts: List[Dict[str, Any]] = [] # 提取查询关键词(简单分词) query_lower = query.lower() @@ -623,28 +810,41 @@ def match_score(text: str) -> int: scored_nodes.append((score, node)) scored_nodes.sort(key=lambda x: x[0], reverse=True) - + for score, node in scored_nodes[:limit]: - nodes_result.append({ - "uuid": node.uuid, - "name": node.name, - "labels": node.labels, - "summary": node.summary, - }) - if node.summary: - facts.append(f"[{node.name}]: {node.summary}") + nodes_result.append(node) + + node_dicts, edges_result, node_facts = self._deduplicate_search_payload( + [self._search_node_info_to_dict(node) for node in nodes_result], + edges_result, + locale, + "local search results", + ) + facts.extend(node_facts) + facts = self._unique_search_facts(facts) - logger.info(f"本地搜索完成: 找到 {len(facts)} 条相关事实") + self._log( + "info", + f"本地搜索完成: 找到 {len(facts)} 条相关事实", + f"Local search completed: found {len(facts)} relevant facts", + locale, + ) except Exception as e: - logger.error(f"本地搜索失败: {str(e)}") + self._log( + "error", + f"本地搜索失败: {str(e)}", + f"Local search failed: {str(e)}", + locale, + ) return SearchResult( facts=facts, edges=edges_result, - nodes=nodes_result, + nodes=node_dicts, query=query, - total_count=len(facts) + total_count=len(facts), + locale=self._locale(), ) def get_all_nodes(self, graph_id: str) -> List[NodeInfo]: @@ -657,24 +857,292 @@ def get_all_nodes(self, graph_id: str) -> List[NodeInfo]: Returns: 节点列表 """ - logger.info(f"获取图谱 {graph_id} 的所有节点...") + locale = self._locale() + self._log( + "info", + f"获取图谱 {graph_id} 的所有节点...", + f"Fetching all nodes for graph {graph_id}...", + locale, + ) - nodes = fetch_all_nodes(self.client, graph_id) + raw_nodes = self._fetch_raw_node_infos(graph_id) - result = [] + result, _ = self._deduplicate_nodes(raw_nodes, "raw graph node introspection") + + self._log( + "info", + f"获取到 {len(result)} 个节点", + f"Fetched {len(result)} nodes", + locale, + ) + return result + + def _fetch_raw_node_infos(self, graph_id: str) -> List[NodeInfo]: + locale = self._locale() + nodes = _fetch_with_optional_locale(fetch_all_nodes, self.client, graph_id, locale) + + raw_nodes = [] for node in nodes: node_uuid = getattr(node, 'uuid_', None) or getattr(node, 'uuid', None) or "" - result.append(NodeInfo( + raw_nodes.append(NodeInfo( uuid=str(node_uuid) if node_uuid else "", name=node.name or "", labels=node.labels or [], summary=node.summary or "", - attributes=node.attributes or {} + attributes=node.attributes or {}, + alias_names=list( + dict.fromkeys( + [ + *( + value + for value in ((node.attributes or {}).get("alias_names", []) or []) + if value + ), + node.name or "", + ] + ) + ), + locale=self._locale(), )) + return raw_nodes + + @staticmethod + def _node_to_entity(node: NodeInfo) -> EntityNode: + return EntityNode( + uuid=node.uuid, + name=node.name, + labels=list(node.labels or []), + summary=node.summary or "", + attributes=dict(node.attributes or {}), + ) + + @staticmethod + def _node_score(node: NodeInfo) -> int: + return ( + len(node.summary or "") + + len(node.labels or []) + + len(node.attributes or {}) * 10 + ) + + @classmethod + def _pick_primary_node(cls, left: NodeInfo, right: NodeInfo) -> NodeInfo: + left_name = ZepEntityReader._normalize_entity_name(left.name) + right_name = ZepEntityReader._normalize_entity_name(right.name) + + if left_name and right_name and len(left_name) != len(right_name): + return left if len(left_name) < len(right_name) else right + + return left if cls._node_score(left) >= cls._node_score(right) else right + + @classmethod + def _deduplicate_nodes( + cls, nodes: List[NodeInfo], log_context: str + ) -> tuple[List[NodeInfo], Dict[str, str]]: + merged_nodes: List[NodeInfo] = [] + uuid_remap: Dict[str, str] = {} - logger.info(f"获取到 {len(result)} 个节点") + for node in nodes: + duplicate_index = next( + ( + idx + for idx, existing in enumerate(merged_nodes) + if ZepEntityReader._are_duplicate_entities( + cls._node_to_entity(existing), + cls._node_to_entity(node), + ) + ), + None, + ) + + if duplicate_index is None: + merged_nodes.append(node) + if node.uuid: + uuid_remap[node.uuid] = node.uuid + continue + + existing = merged_nodes[duplicate_index] + primary = cls._pick_primary_node(existing, node) + secondary = node if primary is existing else existing + + merged_node = NodeInfo( + uuid=primary.uuid, + name=primary.name, + labels=list(dict.fromkeys([*(primary.labels or []), *(secondary.labels or [])])), + summary=max( + [part for part in (primary.summary, secondary.summary) if part], + key=len, + default="", + ), + attributes={ + **(secondary.attributes or {}), + **(primary.attributes or {}), + }, + alias_names=list( + dict.fromkeys( + [ + *(primary.alias_names or []), + *(secondary.alias_names or []), + primary.name, + secondary.name, + ] + ) + ), + locale=primary.locale or secondary.locale, + ) + merged_nodes[duplicate_index] = merged_node + + if existing.uuid: + uuid_remap[existing.uuid] = merged_node.uuid + if node.uuid: + uuid_remap[node.uuid] = merged_node.uuid + + logger.info( + "Collapsing duplicate entity aliases for %s: %s <-> %s", + log_context, + existing.name, + node.name, + ) + + for node in merged_nodes: + if node.uuid: + uuid_remap.setdefault(node.uuid, node.uuid) + + return merged_nodes, uuid_remap + + @staticmethod + def _search_node_info_to_dict(node: NodeInfo) -> Dict[str, Any]: + result = { + "uuid": node.uuid, + "name": node.name, + "labels": list(node.labels or []), + "summary": node.summary, + } + normalized_aliases = list(dict.fromkeys([*(node.alias_names or []), node.name])) + if len(normalized_aliases) > 1: + result["alias_names"] = normalized_aliases return result + @staticmethod + def _search_node_dict_to_info(node: Dict[str, Any], locale: str) -> NodeInfo: + return NodeInfo( + uuid=str(node.get("uuid", "") or ""), + name=str(node.get("name", "") or ""), + labels=list(node.get("labels", []) or []), + summary=str(node.get("summary", "") or ""), + attributes=dict(node.get("attributes", {}) or {}), + alias_names=list( + dict.fromkeys( + [ + *(str(alias) for alias in (node.get("alias_names", []) or []) if alias), + str(node.get("name", "") or ""), + ] + ) + ), + locale=locale, + ) + + @staticmethod + def _unique_search_facts(facts: List[str]) -> List[str]: + result: List[str] = [] + seen = set() + for fact in facts: + normalized = str(fact or "").strip() + if not normalized or normalized in seen: + continue + seen.add(normalized) + result.append(normalized) + return result + + @classmethod + def _deduplicate_search_payload( + cls, + nodes: List[Dict[str, Any]], + edges: List[Dict[str, Any]], + locale: str, + log_context: str, + ) -> tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[str]]: + if not nodes: + return nodes, edges, [] + + node_infos = [cls._search_node_dict_to_info(node, locale) for node in nodes] + deduplicated_nodes, uuid_remap = cls._deduplicate_nodes(node_infos, log_context) + deduplicated_node_dicts = [cls._search_node_info_to_dict(node) for node in deduplicated_nodes] + + deduplicated_edges: List[Dict[str, Any]] = [] + seen_edges = set() + for edge in edges: + remapped = dict(edge) + source_uuid = str(remapped.get("source_node_uuid", "") or "") + target_uuid = str(remapped.get("target_node_uuid", "") or "") + if source_uuid: + remapped["source_node_uuid"] = uuid_remap.get(source_uuid, source_uuid) + if target_uuid: + remapped["target_node_uuid"] = uuid_remap.get(target_uuid, target_uuid) + + edge_key = ( + remapped.get("source_node_uuid", ""), + remapped.get("target_node_uuid", ""), + remapped.get("name", ""), + remapped.get("fact", ""), + ) + if edge_key in seen_edges: + continue + seen_edges.add(edge_key) + deduplicated_edges.append(remapped) + + node_facts = cls._unique_search_facts( + [ + f"[{node.name}]: {node.summary}" + for node in deduplicated_nodes + if node.summary + ] + ) + return deduplicated_node_dicts, deduplicated_edges, node_facts + + @classmethod + def _deduplicate_edge_infos( + cls, + edges: List[EdgeInfo], + uuid_remap: Dict[str, str], + node_map: Dict[str, NodeInfo], + ) -> List[EdgeInfo]: + deduplicated_edges: List[EdgeInfo] = [] + seen_edges = set() + + for edge in edges: + source_uuid = uuid_remap.get(edge.source_node_uuid, edge.source_node_uuid) + target_uuid = uuid_remap.get(edge.target_node_uuid, edge.target_node_uuid) + remapped = EdgeInfo( + uuid=edge.uuid, + name=edge.name, + fact=edge.fact, + source_node_uuid=source_uuid, + target_node_uuid=target_uuid, + source_node_name=node_map.get(source_uuid, NodeInfo('', '', [], '', {})).name or edge.source_node_name, + target_node_name=node_map.get(target_uuid, NodeInfo('', '', [], '', {})).name or edge.target_node_name, + created_at=edge.created_at, + valid_at=edge.valid_at, + invalid_at=edge.invalid_at, + expired_at=edge.expired_at, + locale=edge.locale, + ) + + edge_key = ( + remapped.source_node_uuid, + remapped.target_node_uuid, + remapped.name, + remapped.fact, + remapped.valid_at, + remapped.invalid_at, + remapped.expired_at, + ) + if edge_key in seen_edges: + continue + seen_edges.add(edge_key) + deduplicated_edges.append(remapped) + + return deduplicated_edges + def get_all_edges(self, graph_id: str, include_temporal: bool = True) -> List[EdgeInfo]: """ 获取图谱的所有边(分页获取,包含时间信息) @@ -686,11 +1154,22 @@ def get_all_edges(self, graph_id: str, include_temporal: bool = True) -> List[Ed Returns: 边列表(包含created_at, valid_at, invalid_at, expired_at) """ - logger.info(f"获取图谱 {graph_id} 的所有边...") + locale = self._locale() + self._log( + "info", + f"获取图谱 {graph_id} 的所有边...", + f"Fetching all edges for graph {graph_id}...", + locale, + ) - edges = fetch_all_edges(self.client, graph_id) + deduplicated_nodes, uuid_remap = self._deduplicate_nodes( + self._fetch_raw_node_infos(graph_id), + "raw graph edge introspection", + ) + node_map = {node.uuid: node for node in deduplicated_nodes} + edges = _fetch_with_optional_locale(fetch_all_edges, self.client, graph_id, locale) - result = [] + raw_edges = [] for edge in edges: edge_uuid = getattr(edge, 'uuid_', None) or getattr(edge, 'uuid', None) or "" edge_info = EdgeInfo( @@ -698,7 +1177,8 @@ def get_all_edges(self, graph_id: str, include_temporal: bool = True) -> List[Ed name=edge.name or "", fact=edge.fact or "", source_node_uuid=edge.source_node_uuid or "", - target_node_uuid=edge.target_node_uuid or "" + target_node_uuid=edge.target_node_uuid or "", + locale=self._locale(), ) # 添加时间信息 @@ -708,48 +1188,93 @@ def get_all_edges(self, graph_id: str, include_temporal: bool = True) -> List[Ed edge_info.invalid_at = getattr(edge, 'invalid_at', None) edge_info.expired_at = getattr(edge, 'expired_at', None) - result.append(edge_info) + raw_edges.append(edge_info) + + result = self._deduplicate_edge_infos(raw_edges, uuid_remap, node_map) - logger.info(f"获取到 {len(result)} 条边") + self._log( + "info", + f"获取到 {len(result)} 条边", + f"Fetched {len(result)} edges", + locale, + ) return result - def get_node_detail(self, node_uuid: str) -> Optional[NodeInfo]: + def get_node_detail(self, node_uuid: str, graph_id: Optional[str] = None) -> Optional[NodeInfo]: """ 获取单个节点的详细信息 Args: node_uuid: 节点UUID + graph_id: 图谱ID(可选;提供时会对明显别名执行保守折叠) Returns: 节点信息或None """ - logger.info(f"获取节点详情: {node_uuid[:8]}...") + locale = self._locale() + self._log( + "info", + f"获取节点详情: {node_uuid[:8]}...", + f"Fetching node details: {node_uuid[:8]}...", + locale, + ) try: node = self._call_with_retry( func=lambda: self.client.graph.node.get(uuid_=node_uuid), - operation_name=f"获取节点详情(uuid={node_uuid[:8]}...)" + operation_name=self._text( + f"获取节点详情(uuid={node_uuid[:8]}...)", + f"fetch node details (uuid={node_uuid[:8]}...)", + locale, + ) ) if not node: return None - return NodeInfo( + result = NodeInfo( uuid=getattr(node, 'uuid_', None) or getattr(node, 'uuid', ''), name=node.name or "", labels=node.labels or [], summary=node.summary or "", - attributes=node.attributes or {} + attributes=node.attributes or {}, + locale=self._locale(), + ) + if not graph_id: + return result + + raw_nodes = self._fetch_raw_node_infos(graph_id) + alias_candidates = [ + candidate + for candidate in raw_nodes + if candidate.uuid == result.uuid + or ZepEntityReader._are_duplicate_entities( + self._node_to_entity(result), + self._node_to_entity(candidate), + ) + ] + if not alias_candidates: + return result + + deduplicated_nodes, _ = self._deduplicate_nodes( + alias_candidates, + "node detail lookup", ) + return deduplicated_nodes[0] if deduplicated_nodes else result except Exception as e: - logger.error(f"获取节点详情失败: {str(e)}") + self._log( + "error", + f"获取节点详情失败: {str(e)}", + f"Failed to fetch node details: {str(e)}", + locale, + ) return None def get_node_edges(self, graph_id: str, node_uuid: str) -> List[EdgeInfo]: """ 获取节点相关的所有边 - 通过获取图谱所有边,然后过滤出与指定节点相关的边 + 通过获取图谱所有节点和边,过滤并折叠与指定节点及其明显别名相关的边 Args: graph_id: 图谱ID @@ -758,23 +1283,66 @@ def get_node_edges(self, graph_id: str, node_uuid: str) -> List[EdgeInfo]: Returns: 边列表 """ - logger.info(f"获取节点 {node_uuid[:8]}... 的相关边") + locale = self._locale() + self._log( + "info", + f"获取节点 {node_uuid[:8]}... 的相关边", + f"Fetching edges related to node {node_uuid[:8]}...", + locale, + ) try: - # 获取图谱所有边,然后过滤 + all_nodes = self.get_all_nodes(graph_id) all_edges = self.get_all_edges(graph_id) + + requested_node = next((node for node in all_nodes if node.uuid == node_uuid), None) + if requested_node is None: + result = [ + edge + for edge in all_edges + if edge.source_node_uuid == node_uuid or edge.target_node_uuid == node_uuid + ] + else: + deduplicated_nodes, uuid_remap = self._deduplicate_nodes( + all_nodes, + "node edge lookup", + ) + node_map = {node.uuid: node for node in deduplicated_nodes} + canonical_uuid = uuid_remap.get(node_uuid, node_uuid) + alias_uuids = { + raw_uuid + for raw_uuid, remapped_uuid in uuid_remap.items() + if remapped_uuid == canonical_uuid + } + alias_uuids.add(node_uuid) + alias_uuids.add(canonical_uuid) + + result = self._deduplicate_edge_infos( + [ + edge + for edge in all_edges + if edge.source_node_uuid in alias_uuids + or edge.target_node_uuid in alias_uuids + ], + uuid_remap, + node_map, + ) - result = [] - for edge in all_edges: - # 检查边是否与指定节点相关(作为源或目标) - if edge.source_node_uuid == node_uuid or edge.target_node_uuid == node_uuid: - result.append(edge) - - logger.info(f"找到 {len(result)} 条与节点相关的边") + self._log( + "info", + f"找到 {len(result)} 条与节点相关的边", + f"Found {len(result)} edges related to the node", + locale, + ) return result except Exception as e: - logger.warning(f"获取节点边失败: {str(e)}") + self._log( + "warning", + f"获取节点边失败: {str(e)}", + f"Failed to fetch node edges: {str(e)}", + locale, + ) return [] def get_entities_by_type( @@ -792,7 +1360,13 @@ def get_entities_by_type( Returns: 符合类型的实体列表 """ - logger.info(f"获取类型为 {entity_type} 的实体...") + locale = self._locale() + self._log( + "info", + f"获取类型为 {entity_type} 的实体...", + f"Fetching entities of type {entity_type}...", + locale, + ) all_nodes = self.get_all_nodes(graph_id) @@ -801,9 +1375,15 @@ def get_entities_by_type( # 检查labels是否包含指定类型 if entity_type in node.labels: filtered.append(node) - - logger.info(f"找到 {len(filtered)} 个 {entity_type} 类型的实体") - return filtered + + deduplicated, _ = self._deduplicate_nodes(filtered, "typed entity list") + self._log( + "info", + f"找到 {len(deduplicated)} 个 {entity_type} 类型的实体", + f"Found {len(deduplicated)} entities of type {entity_type}", + locale, + ) + return deduplicated def get_entity_summary( self, @@ -822,7 +1402,13 @@ def get_entity_summary( Returns: 实体摘要信息 """ - logger.info(f"获取实体 {entity_name} 的关系摘要...") + locale = self._locale() + self._log( + "info", + f"获取实体 {entity_name} 的关系摘要...", + f"Fetching relationship summary for entity {entity_name}...", + locale, + ) # 先搜索该实体相关的信息 search_result = self.search_graph( @@ -831,18 +1417,49 @@ def get_entity_summary( limit=20 ) - # 尝试在所有节点中找到该实体 - all_nodes = self.get_all_nodes(graph_id) - entity_node = None - for node in all_nodes: - if node.name.lower() == entity_name.lower(): - entity_node = node - break + # 对节点执行同样的别名折叠,确保实体摘要与搜索/全景输出保持一致。 + all_nodes, uuid_remap = self._deduplicate_nodes( + self.get_all_nodes(graph_id), + "entity summary", + ) + entity_node = next( + ( + node + for node in all_nodes + if node.name.lower() == entity_name.lower() + or ZepEntityReader._normalize_entity_name(node.name) + == ZepEntityReader._normalize_entity_name(entity_name) + or ZepEntityReader._entity_alias_key(self._node_to_entity(node)) + == ZepEntityReader._strip_known_affixes( + ZepEntityReader._normalize_entity_name(entity_name), + next( + (label for label in node.labels if label not in ["Entity", "Node"]), + "", + ), + ) + ), + None, + ) related_edges = [] if entity_node: - # 传入graph_id参数 - related_edges = self.get_node_edges(graph_id, entity_node.uuid) + alias_uuids = { + raw_uuid + for raw_uuid, canonical_uuid in uuid_remap.items() + if canonical_uuid == entity_node.uuid + } + alias_uuids.add(entity_node.uuid) + + related_edges = self._deduplicate_edge_infos( + [ + edge + for edge in self.get_all_edges(graph_id) + if edge.source_node_uuid in alias_uuids + or edge.target_node_uuid in alias_uuids + ], + uuid_remap, + {node.uuid: node for node in all_nodes}, + ) return { "entity_name": entity_name, @@ -862,10 +1479,24 @@ def get_graph_statistics(self, graph_id: str) -> Dict[str, Any]: Returns: 统计信息 """ - logger.info(f"获取图谱 {graph_id} 的统计信息...") + locale = self._locale() + self._log( + "info", + f"获取图谱 {graph_id} 的统计信息...", + f"Fetching graph statistics for {graph_id}...", + locale, + ) - nodes = self.get_all_nodes(graph_id) - edges = self.get_all_edges(graph_id) + nodes, uuid_remap = self._deduplicate_nodes( + self.get_all_nodes(graph_id), + "graph statistics", + ) + node_map = {node.uuid: node for node in nodes} + edges = self._deduplicate_edge_infos( + self.get_all_edges(graph_id), + uuid_remap, + node_map, + ) # 统计实体类型分布 entity_types = {} @@ -906,7 +1537,13 @@ def get_simulation_context( Returns: 模拟上下文信息 """ - logger.info(f"获取模拟上下文: {simulation_requirement[:50]}...") + locale = self._locale() + self._log( + "info", + f"获取模拟上下文: {simulation_requirement[:50]}...", + f"Fetching simulation context: {simulation_requirement[:50]}...", + locale, + ) # 搜索与模拟需求相关的信息 search_result = self.search_graph( @@ -917,9 +1554,12 @@ def get_simulation_context( # 获取图谱统计 stats = self.get_graph_statistics(graph_id) - + # 获取所有实体节点 - all_nodes = self.get_all_nodes(graph_id) + all_nodes, _ = self._deduplicate_nodes( + self.get_all_nodes(graph_id), + "simulation context", + ) # 筛选有实际类型的实体(非纯Entity节点) entities = [] @@ -970,12 +1610,19 @@ def insight_forge( Returns: InsightForgeResult: 深度洞察检索结果 """ - logger.info(f"InsightForge 深度洞察检索: {query[:50]}...") + locale = self._locale() + self._log( + "info", + f"InsightForge 深度洞察检索: {query[:50]}...", + f"InsightForge deep analysis: {query[:50]}...", + locale, + ) result = InsightForgeResult( query=query, simulation_requirement=simulation_requirement, - sub_queries=[] + sub_queries=[], + locale=self._locale(), ) # Step 1: 使用LLM生成子问题 @@ -986,7 +1633,12 @@ def insight_forge( max_queries=max_sub_queries ) result.sub_queries = sub_queries - logger.info(f"生成 {len(sub_queries)} 个子问题") + self._log( + "info", + f"生成 {len(sub_queries)} 个子问题", + f"Generated {len(sub_queries)} sub-queries", + locale, + ) # Step 2: 对每个子问题进行语义搜索 all_facts = [] @@ -1035,36 +1687,53 @@ def insight_forge( entity_uuids.add(target_uuid) # 获取所有相关实体的详情(不限制数量,完整输出) - entity_insights = [] - node_map = {} # 用于后续关系链构建 - + raw_nodes = [] for uuid in list(entity_uuids): # 处理所有实体,不截断 if not uuid: continue try: # 单独获取每个相关节点的信息 - node = self.get_node_detail(uuid) + node = self.get_node_detail(uuid, graph_id=graph_id) if node: - node_map[uuid] = node - entity_type = next((l for l in node.labels if l not in ["Entity", "Node"]), "实体") - - # 获取该实体相关的所有事实(不截断) - related_facts = [ - f for f in all_facts - if node.name.lower() in f.lower() - ] - - entity_insights.append({ - "uuid": node.uuid, - "name": node.name, - "type": entity_type, - "summary": node.summary, - "related_facts": related_facts # 完整输出,不截断 - }) + raw_nodes.append(node) except Exception as e: - logger.debug(f"获取节点 {uuid} 失败: {e}") + self._log( + "debug", + f"获取节点 {uuid} 失败: {e}", + f"Failed to fetch node {uuid}: {e}", + locale, + ) continue - + + deduplicated_nodes, node_uuid_remap = self._deduplicate_nodes( + raw_nodes, + "insight forge output", + ) + node_map = {node.uuid: node for node in deduplicated_nodes} + related_facts_by_uuid: Dict[str, List[str]] = {node.uuid: [] for node in deduplicated_nodes} + + for raw_node in raw_nodes: + canonical_uuid = node_uuid_remap.get(raw_node.uuid, raw_node.uuid) + seen_related = set(related_facts_by_uuid.setdefault(canonical_uuid, [])) + for fact in all_facts: + if raw_node.name and raw_node.name.lower() in fact.lower() and fact not in seen_related: + related_facts_by_uuid[canonical_uuid].append(fact) + seen_related.add(fact) + + entity_insights = [] + for node in deduplicated_nodes: + entity_type = next( + (l for l in node.labels if l not in ["Entity", "Node"]), + self._text("实体", "Entity", locale), + ) + entity_insights.append({ + "uuid": node.uuid, + "name": node.name, + "type": entity_type, + "summary": node.summary, + "related_facts": related_facts_by_uuid.get(node.uuid, []), + }) + result.entity_insights = entity_insights result.total_entities = len(entity_insights) @@ -1072,8 +1741,14 @@ def insight_forge( relationship_chains = [] for edge_data in all_edges: # 处理所有边,不截断 if isinstance(edge_data, dict): - source_uuid = edge_data.get('source_node_uuid', '') - target_uuid = edge_data.get('target_node_uuid', '') + source_uuid = node_uuid_remap.get( + edge_data.get('source_node_uuid', ''), + edge_data.get('source_node_uuid', ''), + ) + target_uuid = node_uuid_remap.get( + edge_data.get('target_node_uuid', ''), + edge_data.get('target_node_uuid', ''), + ) relation_name = edge_data.get('name', '') source_name = node_map.get(source_uuid, NodeInfo('', '', [], '', {})).name or source_uuid[:8] @@ -1086,7 +1761,12 @@ def insight_forge( result.relationship_chains = relationship_chains result.total_relationships = len(relationship_chains) - logger.info(f"InsightForge完成: {result.total_facts}条事实, {result.total_entities}个实体, {result.total_relationships}条关系") + self._log( + "info", + f"InsightForge完成: {result.total_facts}条事实, {result.total_entities}个实体, {result.total_relationships}条关系", + f"InsightForge completed: {result.total_facts} facts, {result.total_entities} entities, {result.total_relationships} relationships", + locale, + ) return result def _generate_sub_queries( @@ -1101,7 +1781,31 @@ def _generate_sub_queries( 将复杂问题分解为多个可以独立检索的子问题 """ - system_prompt = """你是一个专业的问题分析专家。你的任务是将一个复杂问题分解为多个可以在模拟世界中独立观察的子问题。 + locale = self._locale() + if locale == "en": + system_prompt = """You are an expert question analyst. Break a complex question into multiple focused sub-questions that can be observed independently inside the simulation world. + +Requirements: +1. Each sub-question should be specific enough to map to agent behavior, events, or observable changes in the simulation. +2. Cover different dimensions of the main question when possible (for example who, what, why, how, when, where). +3. Keep each sub-question relevant to the simulation scenario. +4. Write every sub-question in natural English, even if the source materials are in another language. +5. Return JSON in the form {"sub_queries": ["Sub-question 1", "Sub-question 2", ...]}.""" + simulation_background = simulation_requirement or "Not provided" + context_prefix = ( + f"\n\nReport context:\n{report_context[:500]}" + if report_context + else "" + ) + user_prompt = ( + f"Simulation background:\n{simulation_background}" + f"{context_prefix}\n\n" + f"Break the following question into {max_queries} focused sub-questions:\n" + f"{query}\n\n" + "Return only the JSON object." + ) + else: + system_prompt = """你是一个专业的问题分析专家。你的任务是将一个复杂问题分解为多个可以在模拟世界中独立观察的子问题。 要求: 1. 每个子问题应该足够具体,可以在模拟世界中找到相关的Agent行为或事件 @@ -1109,7 +1813,7 @@ def _generate_sub_queries( 3. 子问题应该与模拟场景相关 4. 返回JSON格式:{"sub_queries": ["子问题1", "子问题2", ...]}""" - user_prompt = f"""模拟需求背景: + user_prompt = f"""模拟需求背景: {simulation_requirement} {f"报告上下文:{report_context[:500]}" if report_context else ""} @@ -1133,8 +1837,22 @@ def _generate_sub_queries( return [str(sq) for sq in sub_queries[:max_queries]] except Exception as e: - logger.warning(f"生成子问题失败: {str(e)},使用默认子问题") + self._log( + "warning", + f"生成子问题失败: {str(e)},使用默认子问题", + f"Failed to generate sub-queries: {str(e)}. Using default sub-queries.", + self._locale(), + ) # 降级:返回基于原问题的变体 + if locale == "en": + normalized_query = query.rstrip("?.!").strip() or query + return [ + query, + f"Who are the main actors related to {normalized_query}?", + f"What are the causes and impacts of {normalized_query}?", + f"How is {normalized_query} likely to evolve?", + ][:max_queries] + return [ query, f"{query} 的主要参与者", @@ -1168,18 +1886,29 @@ def panorama_search( Returns: PanoramaResult: 广度搜索结果 """ - logger.info(f"PanoramaSearch 广度搜索: {query[:50]}...") + locale = self._locale() + self._log( + "info", + f"PanoramaSearch 广度搜索: {query[:50]}...", + f"PanoramaSearch overview: {query[:50]}...", + locale, + ) - result = PanoramaResult(query=query) + result = PanoramaResult(query=query, locale=self._locale()) # 获取所有节点 - all_nodes = self.get_all_nodes(graph_id) + raw_nodes = self.get_all_nodes(graph_id) + all_nodes, node_uuid_remap = self._deduplicate_nodes(raw_nodes, "panorama output") node_map = {n.uuid: n for n in all_nodes} result.all_nodes = all_nodes result.total_nodes = len(all_nodes) # 获取所有边(包含时间信息) - all_edges = self.get_all_edges(graph_id, include_temporal=True) + all_edges = self._deduplicate_edge_infos( + self.get_all_edges(graph_id, include_temporal=True), + node_uuid_remap, + node_map, + ) result.all_edges = all_edges result.total_edges = len(all_edges) @@ -1192,16 +1921,16 @@ def panorama_search( continue # 为事实添加实体名称 - source_name = node_map.get(edge.source_node_uuid, NodeInfo('', '', [], '', {})).name or edge.source_node_uuid[:8] - target_name = node_map.get(edge.target_node_uuid, NodeInfo('', '', [], '', {})).name or edge.target_node_uuid[:8] + source_name = edge.source_node_name or edge.source_node_uuid[:8] + target_name = edge.target_node_name or edge.target_node_uuid[:8] # 判断是否过期/失效 is_historical = edge.is_expired or edge.is_invalid if is_historical: # 历史/过期事实,添加时间标记 - valid_at = edge.valid_at or "未知" - invalid_at = edge.invalid_at or edge.expired_at or "未知" + valid_at = edge.valid_at or self._text("未知", "Unknown", locale) + invalid_at = edge.invalid_at or edge.expired_at or self._text("未知", "Unknown", locale) fact_with_time = f"[{valid_at} - {invalid_at}] {edge.fact}" historical_facts.append(fact_with_time) else: @@ -1226,12 +1955,20 @@ def relevance_score(fact: str) -> int: active_facts.sort(key=relevance_score, reverse=True) historical_facts.sort(key=relevance_score, reverse=True) + active_facts = self._unique_search_facts(active_facts) + historical_facts = self._unique_search_facts(historical_facts) + result.active_facts = active_facts[:limit] result.historical_facts = historical_facts[:limit] if include_expired else [] result.active_count = len(active_facts) result.historical_count = len(historical_facts) - logger.info(f"PanoramaSearch完成: {result.active_count}条有效, {result.historical_count}条历史") + self._log( + "info", + f"PanoramaSearch完成: {result.active_count}条有效, {result.historical_count}条历史", + f"PanoramaSearch completed: {result.active_count} active facts, {result.historical_count} historical facts", + locale, + ) return result def quick_search( @@ -1256,7 +1993,13 @@ def quick_search( Returns: SearchResult: 搜索结果 """ - logger.info(f"QuickSearch 简单搜索: {query[:50]}...") + locale = self._locale() + self._log( + "info", + f"QuickSearch 简单搜索: {query[:50]}...", + f"QuickSearch: {query[:50]}...", + locale, + ) # 直接调用现有的search_graph方法 result = self.search_graph( @@ -1266,7 +2009,12 @@ def quick_search( scope="edges" ) - logger.info(f"QuickSearch完成: {result.total_count}条结果") + self._log( + "info", + f"QuickSearch完成: {result.total_count}条结果", + f"QuickSearch completed: {result.total_count} results", + locale, + ) return result def interview_agents( @@ -1306,23 +2054,44 @@ def interview_agents( """ from .simulation_runner import SimulationRunner - logger.info(f"InterviewAgents 深度采访(真实API): {interview_requirement[:50]}...") + locale = self._locale() + self._log( + "info", + f"InterviewAgents 深度采访(真实API): {interview_requirement[:50]}...", + f"InterviewAgents deep interview (live API): {interview_requirement[:50]}...", + locale, + ) result = InterviewResult( interview_topic=interview_requirement, - interview_questions=custom_questions or [] + interview_questions=custom_questions or [], + locale=locale, ) # Step 1: 读取人设文件 profiles = self._load_agent_profiles(simulation_id) if not profiles: - logger.warning(f"未找到模拟 {simulation_id} 的人设文件") - result.summary = "未找到可采访的Agent人设文件" + self._log( + "warning", + f"未找到模拟 {simulation_id} 的人设文件", + f"No agent profile files were found for simulation {simulation_id}", + locale, + ) + result.summary = self._text( + "未找到可采访的Agent人设文件", + "No interviewable agent profiles were found", + locale, + ) return result result.total_agents = len(profiles) - logger.info(f"加载到 {len(profiles)} 个Agent人设") + self._log( + "info", + f"加载到 {len(profiles)} 个Agent人设", + f"Loaded {len(profiles)} agent profiles", + locale, + ) # Step 2: 使用LLM选择要采访的Agent(返回agent_id列表) selected_agents, selected_indices, selection_reasoning = self._select_agents_for_interview( @@ -1334,7 +2103,12 @@ def interview_agents( result.selected_agents = selected_agents result.selection_reasoning = selection_reasoning - logger.info(f"选择了 {len(selected_agents)} 个Agent进行采访: {selected_indices}") + self._log( + "info", + f"选择了 {len(selected_agents)} 个Agent进行采访: {selected_indices}", + f"Selected {len(selected_agents)} agents for interview: {selected_indices}", + locale, + ) # Step 3: 生成采访问题(如果没有提供) if not result.interview_questions: @@ -1343,24 +2117,43 @@ def interview_agents( simulation_requirement=simulation_requirement, selected_agents=selected_agents ) - logger.info(f"生成了 {len(result.interview_questions)} 个采访问题") + self._log( + "info", + f"生成了 {len(result.interview_questions)} 个采访问题", + f"Generated {len(result.interview_questions)} interview questions", + locale, + ) # 将问题合并为一个采访prompt combined_prompt = "\n".join([f"{i+1}. {q}" for i, q in enumerate(result.interview_questions)]) # 添加优化前缀,约束Agent回复格式 - INTERVIEW_PROMPT_PREFIX = ( - "你正在接受一次采访。请结合你的人设、所有的过往记忆与行动," - "以纯文本方式直接回答以下问题。\n" - "回复要求:\n" - "1. 直接用自然语言回答,不要调用任何工具\n" - "2. 不要返回JSON格式或工具调用格式\n" - "3. 不要使用Markdown标题(如#、##、###)\n" - "4. 按问题编号逐一回答,每个回答以「问题X:」开头(X为问题编号)\n" - "5. 每个问题的回答之间用空行分隔\n" - "6. 回答要有实质内容,每个问题至少回答2-3句话\n\n" - ) - optimized_prompt = f"{INTERVIEW_PROMPT_PREFIX}{combined_prompt}" + if locale == "en": + interview_prompt_prefix = ( + "You are being interviewed. Combine your persona with your prior memories " + "and actions, then answer the following questions directly in plain text.\n" + "Response requirements:\n" + "1. Answer naturally in plain language and do not call any tools\n" + "2. Do not return JSON or tool-call payloads\n" + "3. Do not use Markdown headings such as #, ##, or ###\n" + "4. Answer each numbered question in order, and begin each answer with " + "\"Question X:\" where X is the question number\n" + "5. Separate each answer with a blank line\n" + "6. Provide substantive content, with at least 2-3 sentences per question\n\n" + ) + else: + interview_prompt_prefix = ( + "你正在接受一次采访。请结合你的人设、所有的过往记忆与行动," + "以纯文本方式直接回答以下问题。\n" + "回复要求:\n" + "1. 直接用自然语言回答,不要调用任何工具\n" + "2. 不要返回JSON格式或工具调用格式\n" + "3. 不要使用Markdown标题(如#、##、###)\n" + "4. 按问题编号逐一回答,每个回答以「问题X:」开头(X为问题编号)\n" + "5. 每个问题的回答之间用空行分隔\n" + "6. 回答要有实质内容,每个问题至少回答2-3句话\n\n" + ) + optimized_prompt = f"{interview_prompt_prefix}{combined_prompt}" # Step 4: 调用真实的采访API(不指定platform,默认双平台同时采访) try: @@ -1373,23 +2166,42 @@ def interview_agents( # 不指定platform,API会在twitter和reddit两个平台都采访 }) - logger.info(f"调用批量采访API(双平台): {len(interviews_request)} 个Agent") + self._log( + "info", + f"调用批量采访API(双平台): {len(interviews_request)} 个Agent", + f"Calling the batch interview API (dual platform) for {len(interviews_request)} agents", + locale, + ) # 调用 SimulationRunner 的批量采访方法(不传platform,双平台采访) api_result = SimulationRunner.interview_agents_batch( simulation_id=simulation_id, interviews=interviews_request, platform=None, # 不指定platform,双平台采访 - timeout=180.0 # 双平台需要更长超时 + timeout=float(Config.INTERVIEW_BATCH_TIMEOUT_SECONDS), ) - logger.info(f"采访API返回: {api_result.get('interviews_count', 0)} 个结果, success={api_result.get('success')}") + self._log( + "info", + f"采访API返回: {api_result.get('interviews_count', 0)} 个结果, success={api_result.get('success')}", + f"Interview API returned {api_result.get('interviews_count', 0)} results, success={api_result.get('success')}", + locale, + ) # 检查API调用是否成功 if not api_result.get("success", False): - error_msg = api_result.get("error", "未知错误") - logger.warning(f"采访API返回失败: {error_msg}") - result.summary = f"采访API调用失败:{error_msg}。请检查OASIS模拟环境状态。" + error_msg = api_result.get("error", self._text("未知错误", "Unknown error", locale)) + self._log( + "warning", + f"采访API返回失败: {error_msg}", + f"Interview API returned failure: {error_msg}", + locale, + ) + result.summary = self._text( + f"采访API调用失败:{error_msg}。请检查OASIS模拟环境状态。", + f"Interview API call failed: {error_msg}. Check the OASIS simulation environment status.", + locale, + ) return result # Step 5: 解析API返回结果,构建AgentInterview对象 @@ -1400,7 +2212,7 @@ def interview_agents( for i, agent_idx in enumerate(selected_indices): agent = selected_agents[i] agent_name = agent.get("realname", agent.get("username", f"Agent_{agent_idx}")) - agent_role = agent.get("profession", "未知") + agent_role = agent.get("profession", self._text("未知", "Unknown", locale)) agent_bio = agent.get("bio", "") # 获取该Agent在两个平台的采访结果 @@ -1415,9 +2227,14 @@ def interview_agents( reddit_response = self._clean_tool_call_response(reddit_response) # 始终输出双平台标记 - twitter_text = twitter_response if twitter_response else "(该平台未获得回复)" - reddit_text = reddit_response if reddit_response else "(该平台未获得回复)" - response_text = f"【Twitter平台回答】\n{twitter_text}\n\n【Reddit平台回答】\n{reddit_text}" + no_reply = self._text("(该平台未获得回复)", "(no reply received from this platform)", locale) + twitter_text = twitter_response if twitter_response else no_reply + reddit_text = reddit_response if reddit_response else no_reply + response_text = self._text( + f"【Twitter平台回答】\n{twitter_text}\n\n【Reddit平台回答】\n{reddit_text}", + f"[Twitter answer]\n{twitter_text}\n\n[Reddit answer]\n{reddit_text}", + locale, + ) # 提取关键引言(从两个平台的回答中) import re @@ -1431,21 +2248,29 @@ def interview_agents( clean_text = re.sub(r'【[^】]+】', '', clean_text) # 策略1(主): 提取完整的有实质内容的句子 - sentences = re.split(r'[。!?]', clean_text) + sentences = re.split(r'[。!?.!?]+(?:["”」])?\s*', clean_text) meaningful = [ s.strip() for s in sentences if 20 <= len(s.strip()) <= 150 - and not re.match(r'^[\s\W,,;;::、]+', s.strip()) - and not s.strip().startswith(('{', '问题')) + and not re.match(r'^[\s\W,,;;::、.!?]+', s.strip()) + and not re.match(r'^(?:\{|\u95ee\u9898\d+|question\s+\d+)', s.strip(), re.IGNORECASE) ] meaningful.sort(key=len, reverse=True) - key_quotes = [s + "。" for s in meaningful[:3]] + sentence_suffix = "." if locale == "en" else "。" + key_quotes = [s + sentence_suffix for s in meaningful[:3]] - # 策略2(补充): 正确配对的中文引号「」内长文本 + # 策略2(补充): 提取正确配对的中英文长引号文本 if not key_quotes: paired = re.findall(r'\u201c([^\u201c\u201d]{15,100})\u201d', clean_text) paired += re.findall(r'\u300c([^\u300c\u300d]{15,100})\u300d', clean_text) - key_quotes = [q for q in paired if not re.match(r'^[,,;;::、]', q)][:3] + paired += re.findall(r'"([^"\n]{15,160})"', clean_text) + paired += re.findall(r"'([^'\n]{15,160})'", clean_text) + key_quotes = [ + q.strip() + for q in paired + if not re.match(r'^[,,;;::、.!?]', q.strip()) + and not re.match(r'^(?:\u95ee\u9898\d+|question\s+\d+)', q.strip(), re.IGNORECASE) + ][:3] interview = AgentInterview( agent_name=agent_name, @@ -1453,7 +2278,8 @@ def interview_agents( agent_bio=agent_bio[:1000], # 扩大bio长度限制 question=combined_prompt, response=response_text, - key_quotes=key_quotes[:5] + key_quotes=key_quotes[:5], + locale=locale, ) result.interviews.append(interview) @@ -1461,14 +2287,32 @@ def interview_agents( except ValueError as e: # 模拟环境未运行 - logger.warning(f"采访API调用失败(环境未运行?): {e}") - result.summary = f"采访失败:{str(e)}。模拟环境可能已关闭,请确保OASIS环境正在运行。" + self._log( + "warning", + f"采访API调用失败(环境未运行?): {e}", + f"Interview API call failed (environment not running?): {e}", + locale, + ) + result.summary = self._text( + f"采访失败:{str(e)}。模拟环境可能已关闭,请确保OASIS环境正在运行。", + f"Interview failed: {str(e)}. The simulation environment may be closed. Make sure the OASIS environment is still running.", + locale, + ) return result except Exception as e: - logger.error(f"采访API调用异常: {e}") + self._log( + "error", + f"采访API调用异常: {e}", + f"Interview API call raised an exception: {e}", + locale, + ) import traceback logger.error(traceback.format_exc()) - result.summary = f"采访过程发生错误:{str(e)}" + result.summary = self._text( + f"采访过程发生错误:{str(e)}", + f"An error occurred during the interview process: {str(e)}", + locale, + ) return result # Step 6: 生成采访摘要 @@ -1478,7 +2322,12 @@ def interview_agents( interview_requirement=interview_requirement ) - logger.info(f"InterviewAgents完成: 采访了 {result.interviewed_count} 个Agent(双平台)") + self._log( + "info", + f"InterviewAgents完成: 采访了 {result.interviewed_count} 个Agent(双平台)", + f"InterviewAgents completed: interviewed {result.interviewed_count} agents (dual platform)", + locale, + ) return result @staticmethod @@ -1521,10 +2370,18 @@ def _load_agent_profiles(self, simulation_id: str) -> List[Dict[str, Any]]: try: with open(reddit_profile_path, 'r', encoding='utf-8') as f: profiles = json.load(f) - logger.info(f"从 reddit_profiles.json 加载了 {len(profiles)} 个人设") + self._log( + "info", + f"从 reddit_profiles.json 加载了 {len(profiles)} 个人设", + f"Loaded {len(profiles)} profiles from reddit_profiles.json", + ) return profiles except Exception as e: - logger.warning(f"读取 reddit_profiles.json 失败: {e}") + self._log( + "warning", + f"读取 reddit_profiles.json 失败: {e}", + f"Failed to read reddit_profiles.json: {e}", + ) # 尝试读取Twitter CSV格式 twitter_profile_path = os.path.join(sim_dir, "twitter_profiles.csv") @@ -1539,12 +2396,20 @@ def _load_agent_profiles(self, simulation_id: str) -> List[Dict[str, Any]]: "username": row.get("username", ""), "bio": row.get("description", ""), "persona": row.get("user_char", ""), - "profession": "未知" + "profession": self._unknown_profession() }) - logger.info(f"从 twitter_profiles.csv 加载了 {len(profiles)} 个人设") + self._log( + "info", + f"从 twitter_profiles.csv 加载了 {len(profiles)} 个人设", + f"Loaded {len(profiles)} profiles from twitter_profiles.csv", + ) return profiles except Exception as e: - logger.warning(f"读取 twitter_profiles.csv 失败: {e}") + self._log( + "warning", + f"读取 twitter_profiles.csv 失败: {e}", + f"Failed to read twitter_profiles.csv: {e}", + ) return profiles @@ -1564,20 +2429,51 @@ def _select_agents_for_interview( - selected_indices: 选中Agent的索引列表(用于API调用) - reasoning: 选择理由 """ + locale = self._locale() # 构建Agent摘要列表 agent_summaries = [] for i, profile in enumerate(profiles): + profession = profile.get("profession") or self._text("未知", "Unknown", locale) summary = { "index": i, "name": profile.get("realname", profile.get("username", f"Agent_{i}")), - "profession": profile.get("profession", "未知"), + "profession": profession, "bio": profile.get("bio", "")[:200], "interested_topics": profile.get("interested_topics", []) } agent_summaries.append(summary) - - system_prompt = """你是一个专业的采访策划专家。你的任务是根据采访需求,从模拟Agent列表中选择最适合采访的对象。 + + if locale == "en": + system_prompt = """You are an expert interview planner. Select the most relevant simulated agents for this interview request. + +Selection criteria: +1. The agent's identity or profession is relevant to the interview topic +2. The agent may hold a unique or valuable perspective +3. Prefer a diverse set of viewpoints (for example supporters, critics, neutral observers, professionals) +4. Prioritize roles directly connected to the event +5. Write the `reasoning` field in natural English, even if the interview requirement or agent bios use another language + +Return JSON: +{ + "selected_indices": [selected agent indices], + "reasoning": "brief explanation" +}""" + simulation_background = simulation_requirement or "Not provided" + user_prompt = f"""Interview requirement: +{interview_requirement} + +Simulation background: +{simulation_background} + +Available agents ({len(agent_summaries)} total): +{json.dumps(agent_summaries, ensure_ascii=False, indent=2)} + +Select up to {max_agents} of the most suitable agents and explain why.""" + default_reasoning = "Selected automatically based on relevance" + default_fallback_reasoning = "Used the default selection strategy" + else: + system_prompt = """你是一个专业的采访策划专家。你的任务是根据采访需求,从模拟Agent列表中选择最适合采访的对象。 选择标准: 1. Agent的身份/职业与采访主题相关 @@ -1590,8 +2486,7 @@ def _select_agents_for_interview( "selected_indices": [选中Agent的索引列表], "reasoning": "选择理由说明" }""" - - user_prompt = f"""采访需求: + user_prompt = f"""采访需求: {interview_requirement} 模拟背景: @@ -1601,6 +2496,8 @@ def _select_agents_for_interview( {json.dumps(agent_summaries, ensure_ascii=False, indent=2)} 请选择最多{max_agents}个最适合采访的Agent,并说明选择理由。""" + default_reasoning = "基于相关性自动选择" + default_fallback_reasoning = "使用默认选择策略" try: response = self.llm.chat_json( @@ -1612,7 +2509,7 @@ def _select_agents_for_interview( ) selected_indices = response.get("selected_indices", [])[:max_agents] - reasoning = response.get("reasoning", "基于相关性自动选择") + reasoning = response.get("reasoning", default_reasoning) # 获取选中的Agent完整信息 selected_agents = [] @@ -1625,11 +2522,16 @@ def _select_agents_for_interview( return selected_agents, valid_indices, reasoning except Exception as e: - logger.warning(f"LLM选择Agent失败,使用默认选择: {e}") + self._log( + "warning", + f"LLM选择Agent失败,使用默认选择: {e}", + f"LLM agent selection failed; using the default selection: {e}", + locale, + ) # 降级:选择前N个 selected = profiles[:max_agents] indices = list(range(min(max_agents, len(profiles)))) - return selected, indices, "使用默认选择策略" + return selected, indices, default_fallback_reasoning def _generate_interview_questions( self, @@ -1638,10 +2540,40 @@ def _generate_interview_questions( selected_agents: List[Dict[str, Any]] ) -> List[str]: """使用LLM生成采访问题""" - - agent_roles = [a.get("profession", "未知") for a in selected_agents] - - system_prompt = """你是一个专业的记者/采访者。根据采访需求,生成3-5个深度采访问题。 + locale = self._locale() + agent_roles = [ + a.get("profession") or self._text("未知", "Unknown", locale) + for a in selected_agents + ] + + if locale == "en": + system_prompt = """You are a professional interviewer. Generate 3-5 in-depth interview questions for this request. + +Question requirements: +1. Use open-ended questions that encourage detailed answers +2. Allow different roles to answer differently +3. Cover facts, opinions, and emotions from multiple angles +4. Keep the wording natural, like a real interview +5. Keep each question concise +6. Ask the question directly without extra framing text +7. Write every question in natural English, even if the source materials are in another language + +Return JSON: {"questions": ["Question 1", "Question 2", ...]}""" + simulation_background = simulation_requirement or "Not provided" + user_prompt = f"""Interview requirement: {interview_requirement} + +Simulation background: {simulation_background} + +Interviewee roles: {', '.join(agent_roles)} + +Generate 3-5 interview questions.""" + default_questions = [ + f"What is your perspective on {interview_requirement}?", + "How does this affect you or the group you represent?", + "What should be changed or improved in response?", + ] + else: + system_prompt = """你是一个专业的记者/采访者。根据采访需求,生成3-5个深度采访问题。 问题要求: 1. 开放性问题,鼓励详细回答 @@ -1652,14 +2584,18 @@ def _generate_interview_questions( 6. 直接提问,不要包含背景说明或前缀 返回JSON格式:{"questions": ["问题1", "问题2", ...]}""" - - user_prompt = f"""采访需求:{interview_requirement} + user_prompt = f"""采访需求:{interview_requirement} 模拟背景:{simulation_requirement if simulation_requirement else "未提供"} 采访对象角色:{', '.join(agent_roles)} 请生成3-5个采访问题。""" + default_questions = [ + f"关于{interview_requirement},您的观点是什么?", + "这件事对您或您所代表的群体有什么影响?", + "您认为应该如何解决或改进这个问题?" + ] try: response = self.llm.chat_json( @@ -1670,15 +2606,19 @@ def _generate_interview_questions( temperature=0.5 ) - return response.get("questions", [f"关于{interview_requirement},您有什么看法?"]) + return response.get( + "questions", + [default_questions[0]], + ) except Exception as e: - logger.warning(f"生成采访问题失败: {e}") - return [ - f"关于{interview_requirement},您的观点是什么?", - "这件事对您或您所代表的群体有什么影响?", - "您认为应该如何解决或改进这个问题?" - ] + self._log( + "warning", + f"生成采访问题失败: {e}", + f"Failed to generate interview questions: {e}", + locale, + ) + return default_questions def _generate_interview_summary( self, @@ -1686,16 +2626,47 @@ def _generate_interview_summary( interview_requirement: str ) -> str: """生成采访摘要""" - + locale = self._locale() if not interviews: - return "未完成任何采访" + return self._text("未完成任何采访", "No interviews were completed", locale) # 收集所有采访内容 interview_texts = [] for interview in interviews: - interview_texts.append(f"【{interview.agent_name}({interview.agent_role})】\n{interview.response[:500]}") - - system_prompt = """你是一个专业的新闻编辑。请根据多位受访者的回答,生成一份采访摘要。 + interview_texts.append( + self._text( + f"【{interview.agent_name}({interview.agent_role})】\n{interview.response[:500]}", + f"[{interview.agent_name} ({interview.agent_role})]\n{interview.response[:500]}", + locale, + ) + ) + + if locale == "en": + system_prompt = """You are a professional news editor. Summarize the interview responses from multiple participants. + +Summary requirements: +1. Distill each participant's main viewpoint +2. Highlight consensus and disagreement +3. Surface the most valuable quotations +4. Stay objective and neutral +5. Keep the summary under 1000 words +6. Write the summary entirely in natural English +7. If interview content contains Chinese or mixed-language text, translate it into fluent English before quoting or summarizing it + +Formatting constraints: +- Use plain-text paragraphs separated by blank lines +- Do not use Markdown headings (such as #, ##, ###) +- Do not use divider lines (such as ---, ***) +- Use standard English quotation marks when quoting interviewees directly +- You may use **bold** for key phrases, but avoid other Markdown syntax""" + user_prompt = f"""Interview topic: {interview_requirement} + +Interview content: +{"".join(interview_texts)} + +Generate the interview summary.""" + else: + system_prompt = """你是一个专业的新闻编辑。请根据多位受访者的回答,生成一份采访摘要。 摘要要求: 1. 提炼各方主要观点 @@ -1711,7 +2682,7 @@ def _generate_interview_summary( - 引用受访者原话时使用中文引号「」 - 可以使用**加粗**标记关键词,但不要使用其他Markdown语法""" - user_prompt = f"""采访主题:{interview_requirement} + user_prompt = f"""采访主题:{interview_requirement} 采访内容: {"".join(interview_texts)} @@ -1730,6 +2701,15 @@ def _generate_interview_summary( return summary except Exception as e: - logger.warning(f"生成采访摘要失败: {e}") + self._log( + "warning", + f"生成采访摘要失败: {e}", + f"Failed to generate the interview summary: {e}", + locale, + ) # 降级:简单拼接 + if locale == "en": + return f"Interviewed {len(interviews)} participants, including: " + ", ".join( + [i.agent_name for i in interviews] + ) return f"共采访了{len(interviews)}位受访者,包括:" + "、".join([i.agent_name for i in interviews]) diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py index 5848792b..5e9bc9f6 100644 --- a/backend/app/utils/__init__.py +++ b/backend/app/utils/__init__.py @@ -2,8 +2,8 @@ 工具模块 """ +from .error_handler import error_response, handle_api_exception, log_error from .file_parser import FileParser from .llm_client import LLMClient -__all__ = ['FileParser', 'LLMClient'] - +__all__ = ['FileParser', 'LLMClient', 'error_response', 'handle_api_exception', 'log_error'] diff --git a/backend/app/utils/error_handler.py b/backend/app/utils/error_handler.py new file mode 100644 index 00000000..6d78c3bd --- /dev/null +++ b/backend/app/utils/error_handler.py @@ -0,0 +1,47 @@ +""" +Shared API error handling helpers. +""" + +from __future__ import annotations + +import traceback +from typing import Optional + +from flask import jsonify + +from ..config import Config + + +def error_response( + message: str, + status_code: int = 500, + *, + include_traceback: Optional[bool] = None, + original_error: Optional[Exception] = None, +): + """Build a consistent JSON error response without leaking tracebacks by default.""" + payload = { + "success": False, + "error": message, + } + + if include_traceback is None: + include_traceback = Config.DEBUG + + if include_traceback and original_error is not None: + payload["traceback"] = traceback.format_exc() + + return jsonify(payload), status_code + + +def log_error(logger, error: Exception, context: str = "") -> None: + """Log the user-facing context plus the full traceback to server logs.""" + message = f"{context}: {error}" if context else str(error) + logger.error(message) + logger.debug(traceback.format_exc()) + + +def handle_api_exception(logger, error: Exception, context: str = ""): + """Log an exception and return the standard API error payload.""" + log_error(logger, error, context) + return error_response(str(error), 500, original_error=error) diff --git a/backend/app/utils/file_parser.py b/backend/app/utils/file_parser.py index 3f1d8ed2..279a996b 100644 --- a/backend/app/utils/file_parser.py +++ b/backend/app/utils/file_parser.py @@ -4,9 +4,14 @@ """ import os +import logging from pathlib import Path from typing import List, Optional +from ..i18n import tr + +logger = logging.getLogger(__name__) + def _read_text_with_fallback(file_path: str) -> str: """ @@ -39,8 +44,8 @@ def _read_text_with_fallback(file_path: str) -> str: best = from_bytes(data).best() if best and best.encoding: encoding = best.encoding - except Exception: - pass + except Exception as e: + logger.debug("charset_normalizer detection failed: %s", e) # 回退到 chardet if not encoding: @@ -48,8 +53,8 @@ def _read_text_with_fallback(file_path: str) -> str: import chardet result = chardet.detect(data) encoding = result.get('encoding') if result else None - except Exception: - pass + except Exception as e: + logger.debug("chardet detection failed: %s", e) # 最终兜底:使用 UTF-8 + replace if not encoding: @@ -64,7 +69,7 @@ class FileParser: SUPPORTED_EXTENSIONS = {'.pdf', '.md', '.markdown', '.txt'} @classmethod - def extract_text(cls, file_path: str) -> str: + def extract_text(cls, file_path: str, locale: Optional[str] = None) -> str: """ 从文件中提取文本 @@ -77,29 +82,29 @@ def extract_text(cls, file_path: str) -> str: path = Path(file_path) if not path.exists(): - raise FileNotFoundError(f"文件不存在: {file_path}") + raise FileNotFoundError(tr("file.not_found", locale, path=file_path)) suffix = path.suffix.lower() if suffix not in cls.SUPPORTED_EXTENSIONS: - raise ValueError(f"不支持的文件格式: {suffix}") + raise ValueError(tr("file.unsupported_type", locale, suffix=suffix)) if suffix == '.pdf': - return cls._extract_from_pdf(file_path) + return cls._extract_from_pdf(file_path, locale=locale) elif suffix in {'.md', '.markdown'}: return cls._extract_from_md(file_path) elif suffix == '.txt': return cls._extract_from_txt(file_path) - raise ValueError(f"无法处理的文件格式: {suffix}") + raise ValueError(tr("file.unhandled_type", locale, suffix=suffix)) @staticmethod - def _extract_from_pdf(file_path: str) -> str: + def _extract_from_pdf(file_path: str, locale: Optional[str] = None) -> str: """从PDF提取文本""" try: import fitz # PyMuPDF except ImportError: - raise ImportError("需要安装PyMuPDF: pip install PyMuPDF") + raise ImportError(tr("file.pdf_dependency_missing", locale)) text_parts = [] with fitz.open(file_path) as doc: @@ -121,12 +126,13 @@ def _extract_from_txt(file_path: str) -> str: return _read_text_with_fallback(file_path) @classmethod - def extract_from_multiple(cls, file_paths: List[str]) -> str: + def extract_from_multiple(cls, file_paths: List[str], locale: Optional[str] = None) -> str: """ 从多个文件提取文本并合并 Args: file_paths: 文件路径列表 + locale: 可选的语言代码,默认使用请求上下文 Returns: 合并后的文本 @@ -135,11 +141,20 @@ def extract_from_multiple(cls, file_paths: List[str]) -> str: for i, file_path in enumerate(file_paths, 1): try: - text = cls.extract_text(file_path) + text = cls.extract_text(file_path, locale=locale) filename = Path(file_path).name - all_texts.append(f"=== 文档 {i}: {filename} ===\n{text}") + all_texts.append(tr("file.multi_doc_header", locale, index=i, filename=filename) + f"\n{text}") except Exception as e: - all_texts.append(f"=== 文档 {i}: {file_path} (提取失败: {str(e)}) ===") + filename = Path(file_path).name or file_path + all_texts.append( + tr( + "file.multi_doc_failed_header", + locale, + index=i, + filename=filename, + details=str(e), + ) + ) return "\n\n".join(all_texts) @@ -186,4 +201,3 @@ def split_text_into_chunks( start = end - overlap if end < len(text) else len(text) return chunks - diff --git a/backend/app/utils/llm_client.py b/backend/app/utils/llm_client.py index 6c1a81f4..8bfa5f10 100644 --- a/backend/app/utils/llm_client.py +++ b/backend/app/utils/llm_client.py @@ -4,11 +4,15 @@ """ import json +import logging import re from typing import Optional, Dict, Any, List -from openai import OpenAI +from openai import OpenAI, APIError, BadRequestError from ..config import Config +from ..i18n import tr + +logger = logging.getLogger(__name__) class LLMClient: @@ -23,20 +27,62 @@ def __init__( self.api_key = api_key or Config.LLM_API_KEY self.base_url = base_url or Config.LLM_BASE_URL self.model = model or Config.LLM_MODEL_NAME + self.default_max_tokens = Config.LLM_MAX_TOKENS if not self.api_key: - raise ValueError("LLM_API_KEY 未配置") + raise ValueError(tr("config.llm_key_missing")) self.client = OpenAI( api_key=self.api_key, base_url=self.base_url ) + + @staticmethod + def _trim_messages(messages: List[Dict[str, str]]) -> List[Dict[str, str]]: + """Trim old context while preserving the initial prompt and recent turns.""" + if len(messages) <= 8: + return messages + + head_count = 2 if len(messages) >= 2 else 1 + tail_count = min(8, len(messages) - head_count) + tail_start = max(head_count, len(messages) - tail_count) + trimmed = messages[:head_count] + messages[tail_start:] + + if len(trimmed) < len(messages): + logger.warning("Trimmed LLM context from %s to %s messages", len(messages), len(trimmed)) + + return trimmed + + @staticmethod + def _is_context_length_error(exc: Exception) -> bool: + text = str(exc).lower() + markers = ( + "context_length", + "maximum context", + "context window", + "too many tokens", + "maximum tokens", + "token limit", + ) + return any(marker in text for marker in markers) + + @staticmethod + def _is_unsupported_response_format_error(exc: Exception) -> bool: + text = str(exc).lower() + markers = ( + "response_format", + "json_object unsupported", + "invalid parameter: response_format", + "unsupported json mode", + "json schema is not supported", + ) + return any(marker in text for marker in markers) def chat( self, messages: List[Dict[str, str]], temperature: float = 0.7, - max_tokens: int = 4096, + max_tokens: Optional[int] = None, response_format: Optional[Dict] = None ) -> str: """ @@ -51,6 +97,9 @@ def chat( Returns: 模型响应文本 """ + if max_tokens is None: + max_tokens = self.default_max_tokens + kwargs = { "model": self.model, "messages": messages, @@ -60,18 +109,80 @@ def chat( if response_format: kwargs["response_format"] = response_format - - response = self.client.chat.completions.create(**kwargs) - content = response.choices[0].message.content - # 部分模型(如MiniMax M2.5)会在content中包含<think>思考内容,需要移除 - content = re.sub(r'<think>[\s\S]*?</think>', '', content).strip() + + try: + response = self.client.chat.completions.create(**kwargs) + except BadRequestError as exc: + if response_format and self._is_unsupported_response_format_error(exc): + logger.warning( + "LLM backend rejected response_format=%s; retrying without JSON mode", + response_format.get("type") if isinstance(response_format, dict) else response_format, + ) + kwargs.pop("response_format", None) + response = self.client.chat.completions.create(**kwargs) + else: + if not self._is_context_length_error(exc): + raise + + trimmed_messages = self._trim_messages(messages) + if len(trimmed_messages) == len(messages): + raise + + logger.warning("Retrying LLM call after context-length failure") + kwargs["messages"] = trimmed_messages + response = self.client.chat.completions.create(**kwargs) + except APIError as exc: + if response_format and self._is_unsupported_response_format_error(exc): + logger.warning( + "LLM backend rejected response_format=%s via APIError; retrying without JSON mode", + response_format.get("type") if isinstance(response_format, dict) else response_format, + ) + kwargs.pop("response_format", None) + response = self.client.chat.completions.create(**kwargs) + else: + logger.exception("LLM API request failed") + raise + + content = response.choices[0].message.content or "" + # 部分模型会在 content 中夹带 <think>...</think>,且标签大小写不固定 + content = re.sub(r'<think\b[^>]*>[\s\S]*?</think>', '', content, flags=re.IGNORECASE).strip() return content + + @staticmethod + def _extract_json_payload(response_text: str) -> str: + """从混合文本中提取可解析的 JSON 负载。""" + text = (response_text or "").strip().lstrip('\ufeff') + + text = re.sub(r'^```(?:json)?\s*\n?', '', text, flags=re.IGNORECASE) + text = re.sub(r'\n?```\s*$', '', text) + text = text.strip() + + try: + json.loads(text) + return text + except json.JSONDecodeError: + pass + + decoder = json.JSONDecoder() + for index, char in enumerate(text): + if char not in '{[': + continue + + try: + _, end = decoder.raw_decode(text[index:]) + candidate = text[index:index + end].strip() + json.loads(candidate) + return candidate + except json.JSONDecodeError: + pass + + return text def chat_json( self, messages: List[Dict[str, str]], temperature: float = 0.3, - max_tokens: int = 4096 + max_tokens: Optional[int] = None ) -> Dict[str, Any]: """ 发送聊天请求并返回JSON @@ -88,16 +199,11 @@ def chat_json( messages=messages, temperature=temperature, max_tokens=max_tokens, - response_format={"type": "json_object"} + # 不设置 response_format,以兼容 LM Studio / Ollama 等仅支持纯文本 JSON 输出的后端 ) - # 清理markdown代码块标记 - cleaned_response = response.strip() - cleaned_response = re.sub(r'^```(?:json)?\s*\n?', '', cleaned_response, flags=re.IGNORECASE) - cleaned_response = re.sub(r'\n?```\s*$', '', cleaned_response) - cleaned_response = cleaned_response.strip() + cleaned_response = self._extract_json_payload(response) try: return json.loads(cleaned_response) except json.JSONDecodeError: - raise ValueError(f"LLM返回的JSON格式无效: {cleaned_response}") - + raise ValueError(tr("llm.invalid_json", payload=cleaned_response)) diff --git a/backend/app/utils/retry.py b/backend/app/utils/retry.py index 819b1cfc..410f09cf 100644 --- a/backend/app/utils/retry.py +++ b/backend/app/utils/retry.py @@ -7,6 +7,7 @@ import random import functools from typing import Callable, Any, Optional, Type, Tuple +from ..i18n import tr from ..utils.logger import get_logger logger = get_logger('mirofish.retry') @@ -52,7 +53,14 @@ def wrapper(*args, **kwargs) -> Any: last_exception = e if attempt == max_retries: - logger.error(f"函数 {func.__name__} 在 {max_retries} 次重试后仍失败: {str(e)}") + logger.error( + tr( + "retry.sync_failed_final", + func_name=func.__name__, + max_retries=max_retries, + error=str(e), + ) + ) raise # 计算延迟 @@ -61,8 +69,13 @@ def wrapper(*args, **kwargs) -> Any: current_delay = current_delay * (0.5 + random.random()) logger.warning( - f"函数 {func.__name__} 第 {attempt + 1} 次尝试失败: {str(e)}, " - f"{current_delay:.1f}秒后重试..." + tr( + "retry.sync_failed_attempt", + func_name=func.__name__, + attempt=attempt + 1, + error=str(e), + delay=current_delay, + ) ) if on_retry: @@ -105,7 +118,14 @@ async def wrapper(*args, **kwargs) -> Any: last_exception = e if attempt == max_retries: - logger.error(f"异步函数 {func.__name__} 在 {max_retries} 次重试后仍失败: {str(e)}") + logger.error( + tr( + "retry.async_failed_final", + func_name=func.__name__, + max_retries=max_retries, + error=str(e), + ) + ) raise current_delay = min(delay, max_delay) @@ -113,8 +133,13 @@ async def wrapper(*args, **kwargs) -> Any: current_delay = current_delay * (0.5 + random.random()) logger.warning( - f"异步函数 {func.__name__} 第 {attempt + 1} 次尝试失败: {str(e)}, " - f"{current_delay:.1f}秒后重试..." + tr( + "retry.async_failed_attempt", + func_name=func.__name__, + attempt=attempt + 1, + error=str(e), + delay=current_delay, + ) ) if on_retry: @@ -176,15 +201,25 @@ def call_with_retry( last_exception = e if attempt == self.max_retries: - logger.error(f"API调用在 {self.max_retries} 次重试后仍失败: {str(e)}") + logger.error( + tr( + "retry.api_failed_final", + max_retries=self.max_retries, + error=str(e), + ) + ) raise current_delay = min(delay, self.max_delay) current_delay = current_delay * (0.5 + random.random()) logger.warning( - f"API调用第 {attempt + 1} 次尝试失败: {str(e)}, " - f"{current_delay:.1f}秒后重试..." + tr( + "retry.api_failed_attempt", + attempt=attempt + 1, + error=str(e), + delay=current_delay, + ) ) time.sleep(current_delay) @@ -224,7 +259,13 @@ def call_batch_with_retry( results.append(result) except Exception as e: - logger.error(f"处理第 {idx + 1} 项失败: {str(e)}") + logger.error( + tr( + "retry.batch_item_failed", + index=idx + 1, + error=str(e), + ) + ) failures.append({ "index": idx, "item": item, @@ -235,4 +276,3 @@ def call_batch_with_retry( raise return results, failures - diff --git a/backend/app/utils/zep_paging.py b/backend/app/utils/zep_paging.py index 943cd1ae..a6a5ce7e 100644 --- a/backend/app/utils/zep_paging.py +++ b/backend/app/utils/zep_paging.py @@ -13,6 +13,7 @@ from zep_cloud import InternalServerError from zep_cloud.client import Zep +from ..i18n import get_locale, tr from .logger import get_logger logger = get_logger('mirofish.zep_paging') @@ -29,6 +30,7 @@ def _fetch_page_with_retry( max_retries: int = _DEFAULT_MAX_RETRIES, retry_delay: float = _DEFAULT_RETRY_DELAY, page_description: str = "page", + locale: str | None = None, **kwargs: Any, ) -> list[Any]: """单页请求,失败时指数退避重试。仅重试网络/IO类瞬态错误。""" @@ -45,12 +47,27 @@ def _fetch_page_with_retry( last_exception = e if attempt < max_retries - 1: logger.warning( - f"Zep {page_description} attempt {attempt + 1} failed: {str(e)[:100]}, retrying in {delay:.1f}s..." + tr( + "zep.paging_failed_attempt", + get_locale(locale), + page_description=page_description, + attempt=attempt + 1, + error=str(e)[:100], + delay=delay, + ) ) time.sleep(delay) delay *= 2 else: - logger.error(f"Zep {page_description} failed after {max_retries} attempts: {str(e)}") + logger.error( + tr( + "zep.paging_failed_final", + get_locale(locale), + page_description=page_description, + max_retries=max_retries, + error=str(e), + ) + ) assert last_exception is not None raise last_exception @@ -63,6 +80,7 @@ def fetch_all_nodes( max_items: int = _MAX_NODES, max_retries: int = _DEFAULT_MAX_RETRIES, retry_delay: float = _DEFAULT_RETRY_DELAY, + locale: str | None = None, ) -> list[Any]: """分页获取图谱节点,最多返回 max_items 条(默认 2000)。每页请求自带重试。""" all_nodes: list[Any] = [] @@ -81,6 +99,7 @@ def fetch_all_nodes( max_retries=max_retries, retry_delay=retry_delay, page_description=f"fetch nodes page {page_num} (graph={graph_id})", + locale=locale, **kwargs, ) if not batch: @@ -89,14 +108,27 @@ def fetch_all_nodes( all_nodes.extend(batch) if len(all_nodes) >= max_items: all_nodes = all_nodes[:max_items] - logger.warning(f"Node count reached limit ({max_items}), stopping pagination for graph {graph_id}") + logger.warning( + tr( + "zep.paging_node_limit", + get_locale(locale), + max_items=max_items, + graph_id=graph_id, + ) + ) break if len(batch) < page_size: break cursor = getattr(batch[-1], "uuid_", None) or getattr(batch[-1], "uuid", None) if cursor is None: - logger.warning(f"Node missing uuid field, stopping pagination at {len(all_nodes)} nodes") + logger.warning( + tr( + "zep.paging_node_missing_uuid", + get_locale(locale), + count=len(all_nodes), + ) + ) break return all_nodes @@ -108,6 +140,7 @@ def fetch_all_edges( page_size: int = _DEFAULT_PAGE_SIZE, max_retries: int = _DEFAULT_MAX_RETRIES, retry_delay: float = _DEFAULT_RETRY_DELAY, + locale: str | None = None, ) -> list[Any]: """分页获取图谱所有边,返回完整列表。每页请求自带重试。""" all_edges: list[Any] = [] @@ -126,6 +159,7 @@ def fetch_all_edges( max_retries=max_retries, retry_delay=retry_delay, page_description=f"fetch edges page {page_num} (graph={graph_id})", + locale=locale, **kwargs, ) if not batch: @@ -137,7 +171,13 @@ def fetch_all_edges( cursor = getattr(batch[-1], "uuid_", None) or getattr(batch[-1], "uuid", None) if cursor is None: - logger.warning(f"Edge missing uuid field, stopping pagination at {len(all_edges)} edges") + logger.warning( + tr( + "zep.paging_edge_missing_uuid", + get_locale(locale), + count=len(all_edges), + ) + ) break return all_edges diff --git a/backend/oasis.LICENSE b/backend/oasis.LICENSE new file mode 100644 index 00000000..c46213dc --- /dev/null +++ b/backend/oasis.LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 @ CAMEL-AI.org + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/backend/oasis/__init__.py b/backend/oasis/__init__.py new file mode 100644 index 00000000..ede59569 --- /dev/null +++ b/backend/oasis/__init__.py @@ -0,0 +1,31 @@ +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +# Licensed under the Apache License, Version 2.0 (the “License”); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an “AS IS” BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +__version__ = "0.2.5" + +from oasis.environment.env_action import LLMAction, ManualAction +from oasis.environment.make import make +from oasis.social_agent import (generate_reddit_agent_graph, + generate_twitter_agent_graph) +from oasis.social_agent.agent import SocialAgent +from oasis.social_agent.agent_graph import AgentGraph +from oasis.social_platform.config import UserInfo +from oasis.social_platform.platform import Platform +from oasis.social_platform.typing import ActionType, DefaultPlatformType +from oasis.testing.show_db import print_db_contents + +__all__ = [ + "make", "Platform", "ActionType", "DefaultPlatformType", "ManualAction", + "LLMAction", "print_db_contents", "AgentGraph", "SocialAgent", "UserInfo", + "generate_reddit_agent_graph", "generate_twitter_agent_graph" +] diff --git a/backend/oasis/clock/__init__.py b/backend/oasis/clock/__init__.py new file mode 100644 index 00000000..3446142f --- /dev/null +++ b/backend/oasis/clock/__init__.py @@ -0,0 +1,16 @@ +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +# Licensed under the Apache License, Version 2.0 (the “License”); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an “AS IS” BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +from .clock import Clock + +__all__ = ["Clock"] diff --git a/backend/oasis/clock/clock.py b/backend/oasis/clock/clock.py new file mode 100644 index 00000000..b0f8e197 --- /dev/null +++ b/backend/oasis/clock/clock.py @@ -0,0 +1,33 @@ +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +# Licensed under the Apache License, Version 2.0 (the “License”); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an “AS IS” BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +from datetime import datetime + + +class Clock: + r"""Clock used for the sandbox.""" + + def __init__(self, k: int = 1): + self.real_start_time = datetime.now() + self.k = k + self.time_step = 0 + + def time_transfer(self, now_time: datetime, + start_time: datetime) -> datetime: + time_diff = now_time - self.real_start_time + adjusted_diff = self.k * time_diff + adjusted_time = start_time + adjusted_diff + return adjusted_time + + def get_time_step(self) -> str: + return str(self.time_step) diff --git a/backend/oasis/environment/__init__.py b/backend/oasis/environment/__init__.py new file mode 100644 index 00000000..d963f8cc --- /dev/null +++ b/backend/oasis/environment/__init__.py @@ -0,0 +1,13 @@ +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +# Licensed under the Apache License, Version 2.0 (the “License”); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an “AS IS” BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== diff --git a/backend/oasis/environment/env.py b/backend/oasis/environment/env.py new file mode 100644 index 00000000..4d815c60 --- /dev/null +++ b/backend/oasis/environment/env.py @@ -0,0 +1,208 @@ +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +# Licensed under the Apache License, Version 2.0 (the “License”); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an “AS IS” BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +import asyncio +import logging +import os +from datetime import datetime +from typing import List, Union + +from oasis.environment.env_action import LLMAction, ManualAction +from oasis.social_agent.agent import SocialAgent +from oasis.social_agent.agent_graph import AgentGraph +from oasis.social_agent.agents_generator import generate_custom_agents +from oasis.social_platform.channel import Channel +from oasis.social_platform.platform import Platform +from oasis.social_platform.typing import (ActionType, DefaultPlatformType, + RecsysType) + +# Create log directory if it doesn't exist +log_dir = "./log" +if not os.path.exists(log_dir): + os.makedirs(log_dir) + +# Configure logger +env_log = logging.getLogger("oasis.env") +env_log.setLevel("INFO") + +# Add file handler to save logs to file +current_time = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") +file_handler = logging.FileHandler(f"{log_dir}/oasis-{current_time}.log", + encoding="utf-8") +file_handler.setLevel("INFO") +file_handler.setFormatter( + logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")) +env_log.addHandler(file_handler) + + +class OasisEnv: + + def __init__( + self, + agent_graph: AgentGraph, + platform: Union[DefaultPlatformType, Platform], + database_path: str = None, + semaphore: int = 128, + ) -> None: + r"""Init the oasis environment. + + Args: + agent_graph: The AgentGraph to use in the simulation. + platform: The platform type to use. Including + `DefaultPlatformType.TWITTER` or `DefaultPlatformType.REDDIT`. + Or you can pass a custom `Platform` instance. + database_path: The path to create a sqlite3 database. The file + extension must be `.db` such as `twitter_simulation.db`. + """ + # Initialize the agent graph + self.agent_graph = agent_graph + # Use a semaphore to limit the number of concurrent requests + self.llm_semaphore = asyncio.Semaphore(semaphore) + if isinstance(platform, DefaultPlatformType): + if database_path is None: + raise ValueError( + "database_path is required for DefaultPlatformType") + self.platform = platform + if platform == DefaultPlatformType.TWITTER: + self.channel = Channel() + self.platform = Platform( + db_path=database_path, + channel=self.channel, + recsys_type="twhin-bert", + refresh_rec_post_count=2, + max_rec_post_len=2, + following_post_count=3, + ) + self.platform_type = DefaultPlatformType.TWITTER + elif platform == DefaultPlatformType.REDDIT: + self.channel = Channel() + self.platform = Platform( + db_path=database_path, + channel=self.channel, + recsys_type="reddit", + allow_self_rating=True, + show_score=True, + max_rec_post_len=100, + refresh_rec_post_count=5, + ) + self.platform_type = DefaultPlatformType.REDDIT + else: + raise ValueError(f"Invalid platform: {platform}. Only " + "DefaultPlatformType.TWITTER or " + "DefaultPlatformType.REDDIT are supported.") + elif isinstance(platform, Platform): + if database_path != platform.db_path: + env_log.warning("database_path is not the same as the " + "platform.db_path, using the platform.db_path") + self.platform = platform + self.channel = platform.channel + if platform.recsys_type == RecsysType.REDDIT: + self.platform_type = DefaultPlatformType.REDDIT + else: + self.platform_type = DefaultPlatformType.TWITTER + else: + raise ValueError( + f"Invalid platform: {platform}. You should pass a " + "DefaultPlatformType or a Platform instance.") + + async def reset(self) -> None: + r"""Start the platform and sign up the agents.""" + self.platform_task = asyncio.create_task(self.platform.running()) + self.agent_graph = await generate_custom_agents( + channel=self.channel, agent_graph=self.agent_graph) + + async def _perform_llm_action(self, agent): + r"""Send the request to the llm model and execute the action. + """ + async with self.llm_semaphore: + return await agent.perform_action_by_llm() + + async def _perform_interview_action(self, agent, interview_prompt: str): + r"""Send the request to the llm model and execute the interview. + """ + async with self.llm_semaphore: + return await agent.perform_interview(interview_prompt) + + async def step( + self, actions: dict[SocialAgent, Union[ManualAction, LLMAction, + List[Union[ManualAction, + LLMAction]]]] + ) -> None: + r"""Update the recommendation system and perform the actions. + + Args: + actions(dict[SocialAgent, Union[ManualAction, LLMAction, + List[Union[ManualAction, LLMAction]]]]): The actions to + perform, including the manual(pre-defined) actions and llm + actions. + Returns: + None + """ + # Update the recommendation system + await self.platform.update_rec_table() + env_log.info("update rec table.") + + # Create tasks for both manual and LLM actions + tasks = [] + for agent, action in actions.items(): + if isinstance(action, list): + for single_action in action: + if isinstance(single_action, ManualAction): + if single_action.action_type == ActionType.INTERVIEW: + # Use the agent's perform_interview method for + # interview actions + interview_prompt = single_action.action_args.get( + "prompt", "") + tasks.append( + self._perform_interview_action( + agent, interview_prompt)) + else: + tasks.append( + agent.perform_action_by_data( + single_action.action_type, + **single_action.action_args)) + elif isinstance(single_action, LLMAction): + tasks.append(self._perform_llm_action(agent)) + else: + if isinstance(action, ManualAction): + if action.action_type == ActionType.INTERVIEW: + # Use the agent's perform_interview method for + # interview actions + interview_prompt = action.action_args.get("prompt", "") + tasks.append( + self._perform_interview_action( + agent, interview_prompt)) + else: + tasks.append( + agent.perform_action_by_data( + action.action_type, **action.action_args)) + elif isinstance(action, LLMAction): + tasks.append(self._perform_llm_action(agent)) + + # Execute all tasks concurrently + await asyncio.gather(*tasks) + env_log.info("performed all actions.") + # # Control some agents to perform actions + # Update the clock + if self.platform_type == DefaultPlatformType.TWITTER: + self.platform.sandbox_clock.time_step += 1 + + async def close(self) -> None: + r"""Stop the platform and close the environment. + """ + await self.channel.write_to_receive_queue( + (None, None, ActionType.EXIT)) + await self.platform_task + env_log.info("Simulation finished! Please check the results in the " + f"database: {self.platform.db_path}. Note that the trace " + "table stored all the actions of the agents.") diff --git a/backend/oasis/environment/env_action.py b/backend/oasis/environment/env_action.py new file mode 100644 index 00000000..d2e44d2c --- /dev/null +++ b/backend/oasis/environment/env_action.py @@ -0,0 +1,45 @@ +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +# Licensed under the Apache License, Version 2.0 (the “License”); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an “AS IS” BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +from dataclasses import dataclass +from typing import Any, Dict + +from oasis.social_platform.typing import ActionType + + +@dataclass +class ManualAction: + r"""Some manual predefined social platform actions that need to be + executed by certain agents. + + Args: + agent_id: The ID of the agent that will perform the action. + action: The action to perform. + args: The arguments to pass to the action. For details of each args in + each action, please refer to + `https://github.com/camel-ai/oasis/blob/main/oasis/social_agent/agent_action.py`. + """ + action_type: ActionType + action_args: Dict[str, Any] + + def init(self, action_type, action_args): + self.action_type = action_type + self.action_args = action_args + + +@dataclass +class LLMAction: + r"""Represents actions generated by a Language Learning Model (LLM).""" + + def init(self): + pass diff --git a/backend/oasis/environment/make.py b/backend/oasis/environment/make.py new file mode 100644 index 00000000..5d790b87 --- /dev/null +++ b/backend/oasis/environment/make.py @@ -0,0 +1,19 @@ +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +# Licensed under the Apache License, Version 2.0 (the “License”); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an “AS IS” BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +from oasis.environment.env import OasisEnv + + +def make(*args, **kwargs): + obj = OasisEnv(*args, **kwargs) + return obj diff --git a/backend/oasis/social_agent/__init__.py b/backend/oasis/social_agent/__init__.py new file mode 100644 index 00000000..003f03a2 --- /dev/null +++ b/backend/oasis/social_agent/__init__.py @@ -0,0 +1,23 @@ +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +# Licensed under the Apache License, Version 2.0 (the “License”); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an “AS IS” BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +from .agent import SocialAgent +from .agent_graph import AgentGraph +from .agents_generator import (generate_agents_100w, + generate_reddit_agent_graph, + generate_twitter_agent_graph) + +__all__ = [ + "SocialAgent", "AgentGraph", "generate_agents_100w", + "generate_reddit_agent_graph", "generate_twitter_agent_graph" +] diff --git a/backend/oasis/social_agent/agent.py b/backend/oasis/social_agent/agent.py new file mode 100644 index 00000000..5747a5df --- /dev/null +++ b/backend/oasis/social_agent/agent.py @@ -0,0 +1,321 @@ +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +# Licensed under the Apache License, Version 2.0 (the “License”); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an “AS IS” BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +from __future__ import annotations + +import inspect +import logging +import sys +from datetime import datetime +from typing import TYPE_CHECKING, Any, Callable, List, Optional, Union + +from camel.agents import ChatAgent +from camel.messages import BaseMessage +from camel.models import BaseModelBackend, ModelManager +from camel.prompts import TextPrompt +from camel.toolkits import FunctionTool +from camel.types import OpenAIBackendRole + +from oasis.social_agent.agent_action import SocialAction +from oasis.social_agent.agent_environment import SocialEnvironment +from oasis.social_platform import Channel +from oasis.social_platform.config import UserInfo +from oasis.social_platform.typing import ActionType + +if TYPE_CHECKING: + from oasis.social_agent import AgentGraph + +if "sphinx" not in sys.modules: + agent_log = logging.getLogger(name="social.agent") + agent_log.setLevel("DEBUG") + + if not agent_log.handlers: + now = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + file_handler = logging.FileHandler( + f"./log/social.agent-{str(now)}.log") + file_handler.setLevel("DEBUG") + file_handler.setFormatter( + logging.Formatter( + "%(levelname)s - %(asctime)s - %(name)s - %(message)s")) + agent_log.addHandler(file_handler) + +ALL_SOCIAL_ACTIONS = [action.value for action in ActionType] + + +class SocialAgent(ChatAgent): + r"""Social Agent.""" + + def __init__(self, + agent_id: int, + user_info: UserInfo, + user_info_template: TextPrompt | None = None, + channel: Channel | None = None, + model: Optional[Union[BaseModelBackend, + List[BaseModelBackend], + ModelManager]] = None, + agent_graph: "AgentGraph" = None, + available_actions: list[ActionType] = None, + tools: Optional[List[Union[FunctionTool, Callable]]] = None, + max_iteration: int = 1, + interview_record: bool = False): + self.social_agent_id = agent_id + self.user_info = user_info + self.channel = channel or Channel() + self.env = SocialEnvironment(SocialAction(agent_id, self.channel)) + if user_info_template is None: + system_message_content = self.user_info.to_system_message() + else: + system_message_content = self.user_info.to_custom_system_message( + user_info_template) + system_message = BaseMessage.make_assistant_message( + role_name="system", + content=system_message_content, # system prompt + ) + + if not available_actions: + agent_log.info("No available actions defined, using all actions.") + self.action_tools = self.env.action.get_openai_function_list() + else: + all_tools = self.env.action.get_openai_function_list() + all_possible_actions = [tool.func.__name__ for tool in all_tools] + + for action in available_actions: + action_name = action.value if isinstance( + action, ActionType) else action + if action_name not in all_possible_actions: + agent_log.warning( + f"Action {action_name} is not supported. Supported " + f"actions are: {', '.join(all_possible_actions)}") + self.action_tools = [ + tool for tool in all_tools if tool.func.__name__ in [ + a.value if isinstance(a, ActionType) else a + for a in available_actions + ] + ] + all_tools = (tools or []) + (self.action_tools or []) + super().__init__( + system_message=system_message, + model=model, + scheduling_strategy='random_model', + tools=all_tools, + ) + self.max_iteration = max_iteration + self.interview_record = interview_record + self.agent_graph = agent_graph + self.test_prompt = ( + "\n" + "Helen is a successful writer who usually writes popular western " + "novels. Now, she has an idea for a new novel that could really " + "make a big impact. If it works out, it could greatly " + "improve her career. But if it fails, she will have spent " + "a lot of time and effort for nothing.\n" + "\n" + "What do you think Helen should do?") + + async def perform_action_by_llm(self): + # Get posts: + env_prompt = await self.env.to_text_prompt() + user_msg = BaseMessage.make_user_message( + role_name="User", + content=( + f"Please perform social media actions after observing the " + f"platform environments. Notice that don't limit your " + f"actions for example to just like the posts. " + f"Here is your social media environment: {env_prompt}")) + try: + agent_log.info( + f"Agent {self.social_agent_id} observing environment: " + f"{env_prompt}") + response = await self.astep(user_msg) + for tool_call in response.info['tool_calls']: + action_name = tool_call.tool_name + args = tool_call.args + agent_log.info(f"Agent {self.social_agent_id} performed " + f"action: {action_name} with args: {args}") + if action_name not in ALL_SOCIAL_ACTIONS: + agent_log.info( + f"Agent {self.social_agent_id} get the result: " + f"{tool_call.result}") + # Abort graph action for if 100w Agent + # self.perform_agent_graph_action(action_name, args) + + return response + except Exception as e: + agent_log.error(f"Agent {self.social_agent_id} error: {e}") + return e + + async def perform_test(self): + """ + doing group polarization test for all agents. + TODO: rewrite the function according to the ChatAgent. + TODO: unify the test and interview function. + """ + # user conduct test to agent + _ = BaseMessage.make_user_message(role_name="User", + content=("You are a twitter user.")) + # Test memory should not be writed to memory. + # self.memory.write_record(MemoryRecord(user_msg, + # OpenAIBackendRole.USER)) + + openai_messages, num_tokens = self.memory.get_context() + + openai_messages = ([{ + "role": + self.system_message.role_name, + "content": + self.system_message.content.split("# RESPONSE FORMAT")[0], + }] + openai_messages + [{ + "role": "user", + "content": self.test_prompt + }]) + + agent_log.info(f"Agent {self.social_agent_id}: {openai_messages}") + # NOTE: this is a temporary solution. + # Camel can not stop updating the agents' memory after stop and astep + # now. + response = await self._aget_model_response( + openai_messages=openai_messages, num_tokens=num_tokens) + content = response.output_messages[0].content + agent_log.info( + f"Agent {self.social_agent_id} receive response: {content}") + return { + "user_id": self.social_agent_id, + "prompt": openai_messages, + "content": content + } + + async def perform_interview(self, interview_prompt: str): + """ + Perform an interview with the agent. + """ + # user conduct test to agent + user_msg = BaseMessage.make_user_message( + role_name="User", content=("You are a twitter user.")) + + if self.interview_record: + # Test memory should not be writed to memory. + self.update_memory(message=user_msg, role=OpenAIBackendRole.SYSTEM) + + openai_messages, num_tokens = self.memory.get_context() + + openai_messages = ([{ + "role": + self.system_message.role_name, + "content": + self.system_message.content.split("# RESPONSE FORMAT")[0], + }] + openai_messages + [{ + "role": "user", + "content": interview_prompt + }]) + + agent_log.info(f"Agent {self.social_agent_id}: {openai_messages}") + # NOTE: this is a temporary solution. + # Camel can not stop updating the agents' memory after stop and astep + # now. + + response = await self._aget_model_response( + openai_messages=openai_messages, num_tokens=num_tokens) + + content = response.output_messages[0].content + + if self.interview_record: + # Test memory should not be writed to memory. + self.update_memory(message=response.output_messages[0], + role=OpenAIBackendRole.USER) + agent_log.info( + f"Agent {self.social_agent_id} receive response: {content}") + + # Record the complete interview (prompt + response) through the channel + interview_data = {"prompt": interview_prompt, "response": content} + result = await self.env.action.perform_action( + interview_data, ActionType.INTERVIEW.value) + + # Return the combined result + return { + "user_id": self.social_agent_id, + "prompt": openai_messages, + "content": content, + "success": result.get("success", False) + } + + async def perform_action_by_hci(self) -> Any: + print("Please choose one function to perform:") + function_list = self.env.action.get_openai_function_list() + for i in range(len(function_list)): + agent_log.info(f"Agent {self.social_agent_id} function: " + f"{function_list[i].func.__name__}") + + selection = int(input("Enter your choice: ")) + if not 0 <= selection < len(function_list): + agent_log.error(f"Agent {self.social_agent_id} invalid input.") + return + func = function_list[selection].func + + params = inspect.signature(func).parameters + args = [] + for param in params.values(): + while True: + try: + value = input(f"Enter value for {param.name}: ") + args.append(value) + break + except ValueError: + agent_log.error("Invalid input, please enter an integer.") + + result = await func(*args) + return result + + async def perform_action_by_data(self, func_name, *args, **kwargs) -> Any: + func_name = func_name.value if isinstance(func_name, + ActionType) else func_name + function_list = self.env.action.get_openai_function_list() + for i in range(len(function_list)): + if function_list[i].func.__name__ == func_name: + func = function_list[i].func + result = await func(*args, **kwargs) + self.update_memory(message=BaseMessage.make_user_message( + role_name=OpenAIBackendRole.SYSTEM, + content=f"Agent {self.social_agent_id} performed " + f"{func_name} with args: {args} and kwargs: {kwargs}" + f"and the result is {result}"), + role=OpenAIBackendRole.SYSTEM) + agent_log.info(f"Agent {self.social_agent_id}: {result}") + return result + raise ValueError(f"Function {func_name} not found in the list.") + + def perform_agent_graph_action( + self, + action_name: str, + arguments: dict[str, Any], + ): + r"""Remove edge if action is unfollow or add edge + if action is follow to the agent graph. + """ + if "unfollow" in action_name: + followee_id: int | None = arguments.get("followee_id", None) + if followee_id is None: + return + self.agent_graph.remove_edge(self.social_agent_id, followee_id) + agent_log.info( + f"Agent {self.social_agent_id} unfollowed Agent {followee_id}") + elif "follow" in action_name: + followee_id: int | None = arguments.get("followee_id", None) + if followee_id is None: + return + self.agent_graph.add_edge(self.social_agent_id, followee_id) + agent_log.info( + f"Agent {self.social_agent_id} followed Agent {followee_id}") + + def __str__(self) -> str: + return (f"{self.__class__.__name__}(agent_id={self.social_agent_id}, " + f"model_type={self.model_type.value})") diff --git a/backend/oasis/social_agent/agent_action.py b/backend/oasis/social_agent/agent_action.py new file mode 100644 index 00000000..078cdbb4 --- /dev/null +++ b/backend/oasis/social_agent/agent_action.py @@ -0,0 +1,758 @@ +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +# Licensed under the Apache License, Version 2.0 (the “License”); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an “AS IS” BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +from typing import Any + +from camel.toolkits import FunctionTool + +from oasis.social_platform.channel import Channel +from oasis.social_platform.typing import ActionType + + +class SocialAction: + + def __init__(self, agent_id: int, channel: Channel): + self.agent_id = agent_id + self.channel = channel + + def get_openai_function_list(self) -> list[FunctionTool]: + return [ + FunctionTool(func) for func in [ + self.create_post, + self.like_post, + self.repost, + self.quote_post, + self.unlike_post, + self.dislike_post, + self.undo_dislike_post, + self.search_posts, + self.search_user, + self.trend, + self.refresh, + self.do_nothing, + self.create_comment, + self.like_comment, + self.dislike_comment, + self.unlike_comment, + self.undo_dislike_comment, + self.follow, + self.unfollow, + self.mute, + self.unmute, + self.purchase_product, + self.interview, + self.report_post, + self.join_group, + self.leave_group, + self.send_to_group, + self.create_group, + self.listen_from_group, + ] + ] + + async def perform_action(self, message: Any, type: str): + message_id = await self.channel.write_to_receive_queue( + (self.agent_id, message, type)) + response = await self.channel.read_from_send_queue(message_id) + return response[2] + + async def sign_up(self, user_name: str, name: str, bio: str): + r"""Signs up a new user with the provided username, name, and bio. + + This method prepares a user message comprising the user's details and + invokes an asynchronous action to perform the sign-up process. On + successful execution, it returns a dictionary indicating success along + with the newly created user ID. + + Args: + user_name (str): The username for the new user. + name (str): The full name of the new user. + bio (str): A brief biography of the new user. + + Returns: + dict: A dictionary with two key-value pairs. The 'success' key + maps to a boolean indicating whether the sign-up was + successful, and 'user_id' key maps to the integer ID of the + newly created user on success. + + Example of a successful return: + {'success': True, 'user_id': 2} + """ + + # print(f"Agent {self.agent_id} is signing up with " + # f"user_name: {user_name}, name: {name}, bio: {bio}") + user_message = (user_name, name, bio) + return await self.perform_action(user_message, ActionType.SIGNUP.value) + + async def refresh(self): + r"""Refresh to get recommended posts. + + This method invokes an asynchronous action to refresh and fetch + recommended posts. On successful execution, it returns a dictionary + indicating success along with a list of posts. Each post in the list + contains details such as post ID, user ID, content, creation date, + and the number of likes. + + Returns: + dict: A dictionary with two key-value pairs. The 'success' key + maps to a boolean indicating whether the refresh is + successful. The 'posts' key maps to a list of dictionaries, + each representing a post with its details. + + Example of a successful return: + { + "success": True, + "posts": [ + { + "post_id": 1, + "user_id": 23, + "content": "This is an example post content.", + "created_at": "2024-05-14T12:00:00Z", + "num_likes": 5 + }, + { + "post_id": 2, + "user_id": 42, + "content": "Another example post content.", + "created_at": "2024-05-14T12:05:00Z", + "num_likes": 15 + } + ] + } + """ + return await self.perform_action(None, ActionType.REFRESH.value) + + async def do_nothing(self): + """Perform no action. + Returns: + dict: A dictionary with 'success' indicating if the removal was + successful. + Example of a successful return: + {"success": True} + """ + return await self.perform_action(None, ActionType.DO_NOTHING.value) + + async def create_post(self, content: str): + r"""Create a new post with the given content. + + This method invokes an asynchronous action to create a new post based + on the provided content. Upon successful execution, it returns a + dictionary indicating success and the ID of the newly created post. + + Args: + content (str): The content of the post to be created. + + Returns: + dict: A dictionary with two key-value pairs. The 'success' key + maps to a boolean indicating whether the post creation was + successful. The 'post_id' key maps to the integer ID of the + newly created post. + + Example of a successful return: + {'success': True, 'post_id': 50} + """ + return await self.perform_action(content, ActionType.CREATE_POST.value) + + async def repost(self, post_id: int): + r"""Repost a specified post. + + This method invokes an asynchronous action to Repost a specified + post. It is identified by the given post ID. Upon successful + execution, it returns a dictionary indicating success and the ID of + the newly created repost. + + Args: + post_id (int): The ID of the post to be reposted. + + Returns: + dict: A dictionary with two key-value pairs. The 'success' key + maps to a boolean indicating whether the Repost creation was + successful. The 'post_id' key maps to the integer ID of the + newly created repost. + + Example of a successful return: + {"success": True, "post_id": 123} + + Note: + Attempting to repost a post that the user has already reposted + will result in a failure. + """ + return await self.perform_action(post_id, ActionType.REPOST.value) + + async def quote_post(self, post_id: int, quote_content: str): + r"""Quote a specified post with a given quote content. + + This method invokes an asynchronous action to quote a specified post + with a given quote content. Upon successful execution, it returns a + dictionary indicating success and the ID of the newly created quote. + + Args: + post_id (int): The ID of the post to be quoted. + quote_content (str): The content of the quote to be created. + + Returns: + dict: A dictionary with two key-value pairs. The 'success' key + maps to a boolean indicating whether the quote creation was + successful. The 'post_id' key maps to the integer ID of the + newly created quote. + + Example of a successful return: + {"success": True, "post_id": 123} + + Note: + Attempting to quote a post that the user has already quoted will + result in a failure. + """ + quote_message = (post_id, quote_content) + return await self.perform_action(quote_message, ActionType.QUOTE_POST) + + async def like_post(self, post_id: int): + r"""Create a new like for a specified post. + + This method invokes an asynchronous action to create a new like for a + post. It is identified by the given post ID. Upon successful + execution, it returns a dictionary indicating success and the ID of + the newly created like. + + Args: + post_id (int): The ID of the post to be liked. + + Returns: + dict: A dictionary with two key-value pairs. The 'success' key + maps to a boolean indicating whether the like creation was + successful. The 'like_id' key maps to the integer ID of the + newly created like. + + Example of a successful return: + {"success": True, "like_id": 123} + + Note: + Attempting to like a post that the user has already liked will + result in a failure. + """ + return await self.perform_action(post_id, ActionType.LIKE_POST.value) + + async def unlike_post(self, post_id: int): + """Remove a like for a post. + + This method removes a like from the database, identified by the + post's ID. It returns a dictionary indicating the success of the + operation and the ID of the removed like. + + Args: + post_id (int): The ID of the post to be unliked. + + Returns: + dict: A dictionary with 'success' indicating if the removal was + successful, and 'like_id' the ID of the removed like. + + Example of a successful return: + {"success": True, "like_id": 123} + + Note: + Attempting to remove a like for a post that the user has not + previously liked will result in a failure. + """ + return await self.perform_action(post_id, ActionType.UNLIKE_POST.value) + + async def dislike_post(self, post_id: int): + r"""Create a new dislike for a specified post. + + This method invokes an asynchronous action to create a new dislike for + a post. It is identified by the given post ID. Upon successful + execution, it returns a dictionary indicating success and the ID of + the newly created dislike. + + Args: + post_id (int): The ID of the post to be disliked. + + Returns: + dict: A dictionary with two key-value pairs. The 'success' key + maps to a boolean indicating whether the dislike creation was + successful. The 'dislike_id' key maps to the integer ID of the + newly created like. + + Example of a successful return: + {"success": True, "dislike_id": 123} + + Note: + Attempting to dislike a post that the user has already liked will + result in a failure. + """ + return await self.perform_action(post_id, + ActionType.DISLIKE_POST.value) + + async def undo_dislike_post(self, post_id: int): + """Remove a dislike for a post. + + This method removes a dislike from the database, identified by the + post's ID. It returns a dictionary indicating the success of the + operation and the ID of the removed dislike. + + Args: + post_id (int): The ID of the post to be unliked. + + Returns: + dict: A dictionary with 'success' indicating if the removal was + successful, and 'dislike_id' the ID of the removed like. + + Example of a successful return: + {"success": True, "dislike_id": 123} + + Note: + Attempting to remove a dislike for a post that the user has not + previously liked will result in a failure. + """ + return await self.perform_action(post_id, + ActionType.UNDO_DISLIKE_POST.value) + + async def search_posts(self, query: str): + r"""Search posts based on a given query. + + This method performs a search operation in the database for posts + that match the given query string. The search considers the + post's content, post ID, and user ID. It returns a dictionary + indicating the operation's success and, if successful, a list of + posts that match the query. + + Args: + query (str): The search query string. The search is performed + against the post's content, post ID, and user ID. + + Returns: + dict: A dictionary with a 'success' key indicating the operation's + success. On success, it includes a 'posts' key with a list of + dictionaries, each representing a post. On failure, it + includes an 'error' message or a 'message' indicating no + posts were found. + + Example of a successful return: + { + "success": True, + "posts": [ + { + "post_id": 1, + "user_id": 42, + "content": "Hello, world!", + "created_at": "2024-05-14T12:00:00Z", + "num_likes": 150 + }, + ... + ] + } + """ + return await self.perform_action(query, ActionType.SEARCH_POSTS.value) + + async def search_user(self, query: str): + r"""Search users based on a given query. + + This asynchronous method performs a search operation in the database + for users that match the given query string. The search considers the + user's username, name, bio, and user ID. It returns a dictionary + indicating the operation's success and, if successful, a list of users + that match the query. + + Args: + query (str): The search query string. The search is performed + against the user's username, name, bio, and user ID. + + Returns: + dict: A dictionary with a 'success' key indicating the operation's + success. On success, it includes a 'users' key with a list of + dictionaries, each representing a user. On failure, it includes + an 'error' message or a 'message' indicating no users were + found. + + Example of a successful return: + { + "success": True, + "users": [ + { + "user_id": 1, + "user_name": "exampleUser", + "name": "John Doe", + "bio": "This is an example bio", + "created_at": "2024-05-14T12:00:00Z", + "num_followings": 100, + "num_followers": 150 + }, + ... + ] + } + """ + return await self.perform_action(query, ActionType.SEARCH_USER.value) + + async def follow(self, followee_id: int): + r"""Follow a user. + + This method allows agent to follow another user (followee). + It checks if the agent initiating the follow request has a + corresponding user ID and if the follow relationship already exists. + + Args: + followee_id (int): The user ID of the user to be followed. + + Returns: + dict: A dictionary with a 'success' key indicating the operation's + success. On success, it includes a 'follow_id' key with the ID + of the newly created follow record. On failure, it includes an + 'error' message. + + Example of a successful return: + {"success": True, "follow_id": 123} + """ + return await self.perform_action(followee_id, ActionType.FOLLOW.value) + + async def unfollow(self, followee_id: int): + r"""Unfollow a user. + + This method allows agent to unfollow another user (followee). It + checks if the agent initiating the unfollow request has a + corresponding user ID and if the follow relationship exists. If so, + it removes the follow record from the database, updates the followers + and followings count for both users, and logs the action. + + Args: + followee_id (int): The user ID of the user to be unfollowed. + + Returns: + dict: A dictionary with a 'success' key indicating the operation's + success. On success, it includes a 'follow_id' key with the ID + of the removed follow record. On failure, it includes an + 'error' message. + + Example of a successful return: + {"success": True, "follow_id": 123} + """ + return await self.perform_action(followee_id, + ActionType.UNFOLLOW.value) + + async def mute(self, mutee_id: int): + r"""Mute a user. + + Allows agent to mute another user. Checks for an existing mute + record before adding a new one to the database. + + Args: + mutee_id (int): ID of the user to be muted. + + Returns: + dict: On success, returns a dictionary with 'success': True and + mute_id' of the new record. On failure, returns 'success': + False and an 'error' message. + + Example of a successful return: + {"success": True, "mutee_id": 123} + """ + return await self.perform_action(mutee_id, ActionType.MUTE.value) + + async def unmute(self, mutee_id: int): + r"""Unmute a user. + + Allows agent to remove a mute on another user. Checks for an + existing mute record before removing it from the database. + + Args: + mutee_id (int): ID of the user to be unmuted. + + Returns: + dict: On success, returns a dictionary with 'success': True and + 'mutee_id' of the unmuted record. On failure, returns + 'success': False and an 'error' message. + + Example of a successful return: + {"success": True, "mutee_id": 123} + """ + return await self.perform_action(mutee_id, ActionType.UNMUTE.value) + + async def trend(self): + r"""Fetch the trending posts within a predefined time period. + + Retrieves the top K posts with the most likes in the last specified + number of days. + + Returns: + dict: On success, returns a dictionary with 'success': True and a + list of 'posts', each post being a dictionary containing + 'post_id', 'user_id', 'content', 'created_at', and + 'num_likes'. On failure, returns 'success': False and an + 'error' message or a message indicating no trending posts + were found. + + Example of a successful return: + { + "success": True, + "posts": [ + { + "post_id": 123, + "user_id": 456, + "content": "Example post content", + "created_at": "2024-05-14T12:00:00", + "num_likes": 789 + }, + ... + ] + } + """ + return await self.perform_action(None, ActionType.TREND.value) + + async def create_comment(self, post_id: int, content: str): + r"""Create a new comment for a specified post given content. + + This method creates a new comment based on the provided content and + associates it with the given post ID. Upon successful execution, it + returns a dictionary indicating success and the ID of the newly created + comment. + + Args: + post_id (int): The ID of the post to which the comment is to be + added. + content (str): The content of the comment to be created. + + Returns: + dict: A dictionary with two key-value pairs. The 'success' key + maps to a boolean indicating whether the comment creation was + successful. The 'comment_id' key maps to the integer ID of the + newly created comment. + + Example of a successful return: + {'success': True, 'comment_id': 123} + """ + comment_message = (post_id, content) + return await self.perform_action(comment_message, + ActionType.CREATE_COMMENT.value) + + async def like_comment(self, comment_id: int): + r"""Create a new like for a specified comment. + + This method invokes an action to create a new like for a comment, + identified by the given comment ID. Upon successful execution, it + returns a dictionary indicating success and the ID of the newly + created like. + + Args: + comment_id (int): The ID of the comment to be liked. + + Returns: + dict: A dictionary with two key-value pairs. The 'success' key + maps to a boolean indicating whether the like creation was + successful. The 'like_id' key maps to the integer ID of the + newly created like. + + Example of a successful return: + {"success": True, "comment_like_id": 456} + + Note: + Attempting to like a comment that the user has already liked will + result in a failure. + """ + return await self.perform_action(comment_id, + ActionType.LIKE_COMMENT.value) + + async def unlike_comment(self, comment_id: int): + """Remove a like for a comment based on the comment's ID. + + This method removes a like from the database, identified by the + comment's ID. It returns a dictionary indicating the success of the + operation and the ID of the removed like. + + Args: + comment_id (int): The ID of the comment to be unliked. + + Returns: + dict: A dictionary with 'success' indicating if the removal was + successful, and 'like_id' the ID of the removed like. + + Example of a successful return: + {"success": True, "like_id": 456} + + Note: + Attempting to remove a like for a comment that the user has not + previously liked will result in a failure. + """ + return await self.perform_action(comment_id, + ActionType.UNLIKE_COMMENT.value) + + async def dislike_comment(self, comment_id: int): + r"""Create a new dislike for a specified comment. + + This method invokes an action to create a new dislike for a + comment, identified by the given comment ID. Upon successful execution, + it returns a dictionary indicating success and the ID of the newly + created dislike. + + Args: + comment_id (int): The ID of the comment to be disliked. + + Returns: + dict: A dictionary with two key-value pairs. The 'success' key + maps to a boolean indicating whether the dislike creation was + successful. The 'dislike_id' key maps to the integer ID of the + newly created dislike. + + Example of a successful return: + {"success": True, "comment_dislike_id": 456} + + Note: + Attempting to dislike a comment that the user has already liked + will result in a failure. + """ + return await self.perform_action(comment_id, + ActionType.DISLIKE_COMMENT.value) + + async def undo_dislike_comment(self, comment_id: int): + """Remove a dislike for a comment. + + This method removes a dislike from the database, identified by the + comment's ID. It returns a dictionary indicating the success of the + operation and the ID of the removed dislike. + + Args: + comment_id (int): The ID of the comment to have the dislike + removed. + + Returns: + dict: A dictionary with 'success' indicating if the removal was + successful, and 'dislike_id' the ID of the removed dislike. + + Example of a successful return: + {"success": True, "dislike_id": 456} + + Note: + Attempting to remove a dislike for a comment that the user has not + previously disliked will result in a failure. + """ + return await self.perform_action(comment_id, + ActionType.UNDO_DISLIKE_COMMENT.value) + + async def purchase_product(self, product_name: str, purchase_num: int): + r"""Purchase a product. + + Args: + product_name (str): The name of the product to be purchased. + purchase_num (int): The number of products to be purchased. + + Returns: + dict: A dictionary with 'success' indicating if the purchase was + successful. + """ + purchase_message = (product_name, purchase_num) + return await self.perform_action(purchase_message, + ActionType.PURCHASE_PRODUCT.value) + + async def interview(self, prompt: str): + r"""Interview an agent with the given prompt. + + This method invokes an asynchronous action to interview an agent with a + specific prompt question. Upon successful execution, + it returns a dictionary containing a success status + and an interview_id for tracking. + + Args: + prompt (str): The interview question or prompt to ask the agent. + + Returns: + dict: A dictionary containing success status and an interview_id. + + Example of a successful return: + { + "success": True, + "interview_id": "1621234567_0" # Timestamp_UserID format + } + """ + return await self.perform_action(prompt, ActionType.INTERVIEW.value) + + async def report_post(self, post_id: int, report_reason: str): + r"""Report a specified post with a given reason. + + This method invokes an asynchronous action to report a specified post + with a given reason. Upon successful execution, it returns a + dictionary indicating success and the ID of the newly created report. + + Args: + post_id (int): The ID of the post to be reported. + report_reason (str): The reason for reporting the post. + + Returns: + dict: A dictionary with two key-value pairs. The 'success' key + maps to a boolean indicating whether the report creation was + successful. The 'report_id' key maps to the integer ID of the + newly created report. + + Example of a successful return: + {"success": True, "report_id": 123} + + Note: + Attempting to report a post that the user has already reported will + result in a failure. + """ + report_message = (post_id, report_reason) + return await self.perform_action(report_message, + ActionType.REPORT_POST.value) + + async def create_group(self, group_name: str): + r"""Creates a new group on the platform. + + Args: + group_name (str): The name of the group to be created. + + Returns: + dict: Platform response indicating success or failure, + e.g.{"success": True, "group_id": 1} + """ + return await self.perform_action(group_name, + ActionType.CREATE_GROUP.value) + + async def join_group(self, group_id: int): + r"""Joins a group with the specified ID. + + Args: + group_id (int): The ID of the group to join. + + Returns: + dict: Platform response indicating success or failure, + e.g. {"success": True} + """ + return await self.perform_action(group_id, ActionType.JOIN_GROUP.value) + + async def leave_group(self, group_id: int): + r"""Leaves a group with the specified ID. + + Args: + group_id (int): The ID of the group to leave. + + Returns: + dict: Platform response indicating success or failure, e.g. + {"success": True} + """ + return await self.perform_action(group_id, + ActionType.LEAVE_GROUP.value) + + async def send_to_group(self, group_id: int, message: str): + r"""Sends a message to a specific group. + + Args: + group_id (int): The ID of the target group. + message (str): The content of the message to send. + + Returns: + dict: Platform response indicating success or failure, e.g. + {"success": True, "message_id": 123} + """ + return await self.perform_action((group_id, message), + ActionType.SEND_TO_GROUP.value) + + async def listen_from_group(self): + r"""Listen messages from groups""" + return await self.perform_action(self.agent_id, + ActionType.LISTEN_FROM_GROUP.value) diff --git a/backend/oasis/social_agent/agent_environment.py b/backend/oasis/social_agent/agent_environment.py new file mode 100644 index 00000000..d2051919 --- /dev/null +++ b/backend/oasis/social_agent/agent_environment.py @@ -0,0 +1,135 @@ +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +# Licensed under the Apache License, Version 2.0 (the “License”); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an “AS IS” BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +from __future__ import annotations + +import json +import sqlite3 +from abc import ABC, abstractmethod +from string import Template + +from oasis.social_agent.agent_action import SocialAction +from oasis.social_platform.database import get_db_path + + +class Environment(ABC): + + @abstractmethod + def to_text_prompt(self) -> str: + r"""Convert the environment to text prompt.""" + raise NotImplementedError + + +class SocialEnvironment(Environment): + followers_env_template = Template("I have $num_followers followers.") + follows_env_template = Template("I have $num_follows follows.") + + posts_env_template = Template( + "After refreshing, you see some posts $posts") + + groups_env_template = Template( + "And there are many group chat channels $all_groups\n" + "And You are already in some groups $joined_groups\n" + "You receive some messages from them $messages\n" + "You can join the groups you are interested, " + "leave the groups you already in, send messages to the group " + "you already in.\n" + "You must make sure you can only send messages to the group you " + "are already in") + env_template = Template( + "$groups_env\n" + "$posts_env\npick one you want to perform action that best " + "reflects your current inclination based on your profile and " + "posts content. Do not limit your action in just `like` to like posts") + + def __init__(self, action: SocialAction): + self.action = action + + async def get_posts_env(self) -> str: + posts = await self.action.refresh() + # TODO: Replace posts json format string to other formats + if posts["success"]: + posts_env = json.dumps(posts["posts"], indent=4) + posts_env = self.posts_env_template.substitute(posts=posts_env) + else: + posts_env = "After refreshing, there are no existing posts." + return posts_env + + async def get_followers_env(self) -> str: + # TODO: Implement followers env + agent_id = self.action.agent_id + db_path = get_db_path() + try: + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + cursor.execute("SELECT num_followers FROM user WHERE agent_id = ?", + (agent_id, )) + result = cursor.fetchone() + num_followers = result[0] if result else 0 + conn.close() + except Exception: + num_followers = 0 + return self.followers_env_template.substitute( + {"num_followers": num_followers}) + + async def get_follows_env(self) -> str: + # TODO: Implement follows env + agent_id = self.action.agent_id + try: + db_path = get_db_path() + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + cursor.execute( + "SELECT num_followings FROM user WHERE agent_id = ?", + (agent_id, )) + result = cursor.fetchone() + num_followings = result[0] if result else 0 + conn.close() + except Exception: + num_followings = 0 + return self.follows_env_template.substitute( + {"num_follows": num_followings}) + + async def get_group_env(self) -> str: + groups = await self.action.listen_from_group() + if groups["success"]: + all_groups = json.dumps(groups["all_groups"]) + joined_groups = json.dumps(groups["joined_groups"]) + messages = json.dumps(groups["messages"]) + groups_env = self.groups_env_template.substitute( + all_groups=all_groups, + joined_groups=joined_groups, + messages=messages, + ) + else: + groups_env = "No groups." + return groups_env + + async def to_text_prompt( + self, + include_posts: bool = True, + include_followers: bool = True, + include_follows: bool = True, + ) -> str: + followers_env = (await self.get_followers_env() + if include_follows else "No followers.") + follows_env = (await self.get_follows_env() + if include_followers else "No follows.") + posts_env = await self.get_posts_env() if include_posts else "" + + return self.env_template.substitute( + followers_env=followers_env, + follows_env=follows_env, + posts_env=posts_env, + groups_env=await self.get_group_env(), + ) diff --git a/backend/oasis/social_agent/agent_graph.py b/backend/oasis/social_agent/agent_graph.py new file mode 100644 index 00000000..c7819314 --- /dev/null +++ b/backend/oasis/social_agent/agent_graph.py @@ -0,0 +1,292 @@ +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +# Licensed under the Apache License, Version 2.0 (the “License”); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an “AS IS” BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +from __future__ import annotations + +from typing import Any, Literal + +import igraph as ig +from neo4j import GraphDatabase + +from oasis.social_agent.agent import SocialAgent +from oasis.social_platform.config import Neo4jConfig + + +class Neo4jHandler: + + def __init__(self, nei4j_config: Neo4jConfig): + self.driver = GraphDatabase.driver( + nei4j_config.uri, + auth=(nei4j_config.username, nei4j_config.password), + ) + self.driver.verify_connectivity() + + def close(self): + self.driver.close() + + def create_agent(self, agent_id: int): + with self.driver.session() as session: + session.write_transaction(self._create_and_return_agent, agent_id) + + def delete_agent(self, agent_id: int): + with self.driver.session() as session: + session.write_transaction( + self._delete_agent_and_relationships, + agent_id, + ) + + def get_number_of_nodes(self) -> int: + with self.driver.session() as session: + return session.read_transaction(self._get_number_of_nodes) + + def get_number_of_edges(self) -> int: + with self.driver.session() as session: + return session.read_transaction(self._get_number_of_edges) + + def add_edge(self, src_agent_id: int, dst_agent_id: int): + with self.driver.session() as session: + session.write_transaction( + self._add_and_return_edge, + src_agent_id, + dst_agent_id, + ) + + def remove_edge(self, src_agent_id: int, dst_agent_id: int): + with self.driver.session() as session: + session.write_transaction( + self._remove_and_return_edge, + src_agent_id, + dst_agent_id, + ) + + def get_all_nodes(self) -> list[int]: + with self.driver.session() as session: + return session.read_transaction(self._get_all_nodes) + + def get_all_edges(self) -> list[tuple[int, int]]: + with self.driver.session() as session: + return session.read_transaction(self._get_all_edges) + + def reset_graph(self): + with self.driver.session() as session: + session.write_transaction(self._reset_graph) + + @staticmethod + def _create_and_return_agent(tx: Any, agent_id: int): + query = """ + CREATE (a:Agent {id: $agent_id}) + RETURN a + """ + result = tx.run(query, agent_id=agent_id) + return result.single() + + @staticmethod + def _delete_agent_and_relationships(tx: Any, agent_id: int): + query = """ + MATCH (a:Agent {id: $agent_id}) + DETACH DELETE a + RETURN count(a) AS deleted + """ + result = tx.run(query, agent_id=agent_id) + return result.single() + + @staticmethod + def _add_and_return_edge(tx: Any, src_agent_id: int, dst_agent_id: int): + query = """ + MATCH (a:Agent {id: $src_agent_id}), (b:Agent {id: $dst_agent_id}) + CREATE (a)-[r:FOLLOW]->(b) + RETURN r + """ + result = tx.run(query, + src_agent_id=src_agent_id, + dst_agent_id=dst_agent_id) + return result.single() + + @staticmethod + def _remove_and_return_edge(tx: Any, src_agent_id: int, dst_agent_id: int): + query = """ + MATCH (a:Agent {id: $src_agent_id}) + MATCH (b:Agent {id: $dst_agent_id}) + MATCH (a)-[r:FOLLOW]->(b) + DELETE r + RETURN count(r) AS deleted + """ + result = tx.run(query, + src_agent_id=src_agent_id, + dst_agent_id=dst_agent_id) + return result.single() + + @staticmethod + def _get_number_of_nodes(tx: Any) -> int: + query = """ + MATCH (n) + RETURN count(n) AS num_nodes + """ + result = tx.run(query) + return result.single()["num_nodes"] + + @staticmethod + def _get_number_of_edges(tx: Any) -> int: + query = """ + MATCH ()-[r]->() + RETURN count(r) AS num_edges + """ + result = tx.run(query) + return result.single()["num_edges"] + + @staticmethod + def _get_all_nodes(tx: Any) -> list[int]: + query = """ + MATCH (a:Agent) + RETURN a.id AS agent_id + """ + result = tx.run(query) + return [record["agent_id"] for record in result] + + @staticmethod + def _get_all_edges(tx: Any) -> list[tuple[int, int]]: + query = """ + MATCH (a:Agent)-[r:FOLLOW]->(b:Agent) + RETURN a.id AS src_agent_id, b.id AS dst_agent_id + """ + result = tx.run(query) + return [(record["src_agent_id"], record["dst_agent_id"]) + for record in result] + + @staticmethod + def _reset_graph(tx: Any): + query = """ + MATCH (n) + DETACH DELETE n + """ + tx.run(query) + + +class AgentGraph: + r"""AgentGraph class to manage the social graph of agents.""" + + def __init__( + self, + backend: Literal["igraph", "neo4j"] = "igraph", + neo4j_config: Neo4jConfig | None = None, + ): + self.backend = backend + if self.backend == "igraph": + self.graph = ig.Graph(directed=True) + else: + assert neo4j_config is not None + assert neo4j_config.is_valid() + self.graph = Neo4jHandler(neo4j_config) + self.agent_mappings: dict[int, SocialAgent] = {} + + def reset(self): + if self.backend == "igraph": + self.graph = ig.Graph(directed=True) + else: + self.graph.reset_graph() + self.agent_mappings: dict[int, SocialAgent] = {} + + def add_agent(self, agent: SocialAgent): + if self.backend == "igraph": + self.graph.add_vertex(agent.social_agent_id) + else: + self.graph.create_agent(agent.social_agent_id) + self.agent_mappings[agent.social_agent_id] = agent + + def add_edge(self, agent_id_0: int, agent_id_1: int): + try: + self.graph.add_edge(agent_id_0, agent_id_1) + except Exception: + pass + + def remove_agent(self, agent: SocialAgent): + if self.backend == "igraph": + self.graph.delete_vertices(agent.social_agent_id) + else: + self.graph.delete_agent(agent.social_agent_id) + del self.agent_mappings[agent.social_agent_id] + + def remove_edge(self, agent_id_0: int, agent_id_1: int): + if self.backend == "igraph": + if self.graph.are_connected(agent_id_0, agent_id_1): + self.graph.delete_edges([(agent_id_0, agent_id_1)]) + else: + self.graph.remove_edge(agent_id_0, agent_id_1) + + def get_agent(self, agent_id: int) -> SocialAgent: + return self.agent_mappings[agent_id] + + def get_agents( + self, + agent_ids: list[int] = None) -> list[tuple[int, SocialAgent]]: + if agent_ids: + return [(agent_id, self.get_agent(agent_id)) + for agent_id in agent_ids] + if self.backend == "igraph": + return [(node.index, self.agent_mappings[node.index]) + for node in self.graph.vs] + else: + return [(agent_id, self.agent_mappings[agent_id]) + for agent_id in self.graph.get_all_nodes()] + + def get_edges(self) -> list[tuple[int, int]]: + if self.backend == "igraph": + return [(edge.source, edge.target) for edge in self.graph.es] + else: + return self.graph.get_all_edges() + + def get_num_nodes(self) -> int: + if self.backend == "igraph": + return self.graph.vcount() + else: + return self.graph.get_number_of_nodes() + + def get_num_edges(self) -> int: + if self.backend == "igraph": + return self.graph.ecount() + else: + return self.graph.get_number_of_edges() + + def close(self) -> None: + if self.backend == "neo4j": + self.graph.close() + + def visualize( + self, + path: str, + vertex_size: int = 20, + edge_arrow_size: float = 0.5, + with_labels: bool = True, + vertex_color: str = "#f74f1b", + vertex_frame_width: int = 2, + width: int = 1000, + height: int = 1000, + ): + if self.backend == "neo4j": + raise ValueError("Neo4j backend does not support visualization.") + layout = self.graph.layout("auto") + if with_labels: + labels = [node_id for node_id, _ in self.get_agents()] + else: + labels = None + ig.plot( + self.graph, + target=path, + layout=layout, + vertex_label=labels, + vertex_size=vertex_size, + vertex_color=vertex_color, + edge_arrow_size=edge_arrow_size, + vertex_frame_width=vertex_frame_width, + bbox=(width, height), + ) diff --git a/backend/oasis/social_agent/agents_generator.py b/backend/oasis/social_agent/agents_generator.py new file mode 100644 index 00000000..65435906 --- /dev/null +++ b/backend/oasis/social_agent/agents_generator.py @@ -0,0 +1,649 @@ +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +# Licensed under the Apache License, Version 2.0 (the “License”); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an “AS IS” BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +from __future__ import annotations + +import ast +import asyncio +import json +from typing import List, Optional, Union + +import pandas as pd +import tqdm +from camel.memories import MemoryRecord +from camel.messages import BaseMessage +from camel.models import BaseModelBackend, ModelManager +from camel.types import OpenAIBackendRole + +from oasis.social_agent import AgentGraph, SocialAgent +from oasis.social_platform import Channel, Platform +from oasis.social_platform.config import Neo4jConfig, UserInfo +from oasis.social_platform.typing import ActionType + + +async def generate_agents( + agent_info_path: str, + channel: Channel, + model: Union[BaseModelBackend, List[BaseModelBackend]], + start_time, + recsys_type: str = "twitter", + twitter: Platform = None, + available_actions: list[ActionType] = None, + neo4j_config: Neo4jConfig | None = None, +) -> AgentGraph: + """TODO: need update the description of args and check + Generate and return a dictionary of agents from the agent + information CSV file. Each agent is added to the database and + their respective profiles are updated. + + Args: + agent_info_path (str): The file path to the agent information CSV file. + channel (Channel): Information channel. + action_space_prompt (str): determine the action space of agents. + model_random_seed (int): Random seed to randomly assign model to + each agent. (default: 42) + cfgs (list, optional): List of configuration. (default: `None`) + neo4j_config (Neo4jConfig, optional): Neo4j graph database + configuration. (default: `None`) + + Returns: + dict: A dictionary of agent IDs mapped to their respective agent + class instances. + """ + agent_info = pd.read_csv(agent_info_path) + + agent_graph = (AgentGraph() if neo4j_config is None else AgentGraph( + backend="neo4j", + neo4j_config=neo4j_config, + )) + + # agent_graph = [] + sign_up_list = [] + follow_list = [] + user_update1 = [] + user_update2 = [] + post_list = [] + + for agent_id in range(len(agent_info)): + profile = { + "nodes": [], + "edges": [], + "other_info": {}, + } + profile["other_info"]["user_profile"] = agent_info["user_char"][ + agent_id] + + user_info = UserInfo( + name=agent_info["username"][agent_id], + description=agent_info["description"][agent_id], + profile=profile, + recsys_type=recsys_type, + ) + + agent = SocialAgent( + agent_id=agent_id, + user_info=user_info, + channel=channel, + model=model, + agent_graph=agent_graph, + available_actions=available_actions, + ) + + agent_graph.add_agent(agent) + # TODO we should not use following_count and followers_count + # We should calculate the number of followings and followers + # based on the graph because the following situation is dynamic. + num_followings = 0 + num_followers = 0 + + sign_up_list.append(( + agent_id, + agent_id, + agent_info["username"][agent_id], + agent_info["name"][agent_id], + agent_info["description"][agent_id], + start_time, + num_followings, + num_followers, + )) + + following_id_list = ast.literal_eval( + agent_info["following_agentid_list"][agent_id]) + if not isinstance(following_id_list, int): + if len(following_id_list) != 0: + for follow_id in following_id_list: + follow_list.append((agent_id, follow_id, start_time)) + user_update1.append((agent_id, )) + user_update2.append((follow_id, )) + agent_graph.add_edge(agent_id, follow_id) + + previous_posts = ast.literal_eval( + agent_info["previous_tweets"][agent_id]) + if len(previous_posts) != 0: + for post in previous_posts: + post_list.append((agent_id, post, start_time, 0, 0)) + + # generate_log.info('agent gegenerate finished.') + + user_insert_query = ( + "INSERT INTO user (user_id, agent_id, user_name, name, bio, " + "created_at, num_followings, num_followers) VALUES " + "(?, ?, ?, ?, ?, ?, ?, ?)") + twitter.pl_utils._execute_many_db_command(user_insert_query, + sign_up_list, + commit=True) + + follow_insert_query = ( + "INSERT INTO follow (follower_id, followee_id, created_at) " + "VALUES (?, ?, ?)") + twitter.pl_utils._execute_many_db_command(follow_insert_query, + follow_list, + commit=True) + user_update_query1 = ( + "UPDATE user SET num_followings = num_followings + 1 " + "WHERE user_id = ?") + twitter.pl_utils._execute_many_db_command(user_update_query1, + user_update1, + commit=True) + + user_update_query2 = ("UPDATE user SET num_followers = num_followers + 1 " + "WHERE user_id = ?") + twitter.pl_utils._execute_many_db_command(user_update_query2, + user_update2, + commit=True) + + # generate_log.info('twitter followee update finished.') + + post_insert_query = ( + "INSERT INTO post (user_id, content, created_at, num_likes, " + "num_dislikes) VALUES (?, ?, ?, ?, ?)") + twitter.pl_utils._execute_many_db_command(post_insert_query, + post_list, + commit=True) + + # generate_log.info('twitter creat post finished.') + + return agent_graph + + +async def generate_agents_100w( + agent_info_path: str, + channel: Channel, + start_time, + model: Union[BaseModelBackend, List[BaseModelBackend]], + recsys_type: str = "twitter", + twitter: Platform = None, + available_actions: list[ActionType] = None, +) -> List: + """ TODO: need update the description of args. + Generate and return a dictionary of agents from the agent + information CSV file. Each agent is added to the database and + their respective profiles are updated. + + Args: + agent_info_path (str): The file path to the agent information CSV file. + channel (Channel): Information channel. + action_space_prompt (str): determine the action space of agents. + model_random_seed (int): Random seed to randomly assign model to + each agent. (default: 42) + + Returns: + dict: A dictionary of agent IDs mapped to their respective agent + class instances. + """ + agent_info = pd.read_csv(agent_info_path) + + # TODO when setting 100w agents, the agentgraph class is too slow. + # I use the list. + agent_graph = [] + # agent_graph = (AgentGraph() if neo4j_config is None else AgentGraph( + # backend="neo4j", + # neo4j_config=neo4j_config, + # )) + + # agent_graph = [] + sign_up_list = [] + follow_list = [] + user_update1 = [] + user_update2 = [] + post_list = [] + + # precompute to speed up agent generation in one million scale + _ = agent_info["following_agentid_list"].apply(ast.literal_eval) + previous_tweets_lists = agent_info["previous_tweets"].apply( + ast.literal_eval) + previous_tweets_lists = agent_info['previous_tweets'].apply( + ast.literal_eval) + following_id_lists = agent_info["following_agentid_list"].apply( + ast.literal_eval) + + for agent_id in tqdm.tqdm(range(len(agent_info))): + profile = { + "nodes": [], + "edges": [], + "other_info": {}, + } + profile["other_info"]["user_profile"] = agent_info["user_char"][ + agent_id] + # TODO if you simulate one million agents, use active threshold below. + # profile['other_info']['active_threshold'] = [0.01] * 24 + + user_info = UserInfo( + name=agent_info["username"][agent_id], + description=agent_info["description"][agent_id], + profile=profile, + recsys_type=recsys_type, + ) + + agent = SocialAgent( + agent_id=agent_id, + user_info=user_info, + channel=channel, + model=model, + agent_graph=agent_graph, + available_actions=available_actions, + ) + + agent_graph.append(agent) + num_followings = 0 + num_followers = 0 + # print('agent_info["following_count"]', agent_info["following_count"]) + + # TODO some data does not cotain this key. + if 'following_count' not in agent_info.columns: + agent_info['following_count'] = 0 + if 'followers_count' not in agent_info.columns: + agent_info['followers_count'] = 0 + + if not agent_info["following_count"].empty: + num_followings = agent_info["following_count"][agent_id] + if not agent_info["followers_count"].empty: + num_followers = agent_info["followers_count"][agent_id] + + sign_up_list.append(( + agent_id, + agent_id, + agent_info["username"][agent_id], + agent_info["name"][agent_id], + agent_info["description"][agent_id], + start_time, + num_followings, + num_followers, + )) + + following_id_list = following_id_lists[agent_id] + + # TODO If we simulate 1 million agents, we can not use agent_graph + # class. It is not scalble. + if not isinstance(following_id_list, int): + if len(following_id_list) != 0: + for follow_id in following_id_list: + follow_list.append((agent_id, follow_id, start_time)) + user_update1.append((agent_id, )) + user_update2.append((follow_id, )) + # agent_graph.add_edge(agent_id, follow_id) + + previous_posts = previous_tweets_lists[agent_id] + if len(previous_posts) != 0: + for post in previous_posts: + post_list.append((agent_id, post, start_time, 0, 0)) + + # generate_log.info('agent gegenerate finished.') + + user_insert_query = ( + "INSERT INTO user (user_id, agent_id, user_name, name, bio, " + "created_at, num_followings, num_followers) VALUES " + "(?, ?, ?, ?, ?, ?, ?, ?)") + twitter.pl_utils._execute_many_db_command(user_insert_query, + sign_up_list, + commit=True) + + follow_insert_query = ( + "INSERT INTO follow (follower_id, followee_id, created_at) " + "VALUES (?, ?, ?)") + twitter.pl_utils._execute_many_db_command(follow_insert_query, + follow_list, + commit=True) + + if not (agent_info["following_count"].empty + and agent_info["followers_count"].empty): + user_update_query1 = ( + "UPDATE user SET num_followings = num_followings + 1 " + "WHERE user_id = ?") + twitter.pl_utils._execute_many_db_command(user_update_query1, + user_update1, + commit=True) + + user_update_query2 = ( + "UPDATE user SET num_followers = num_followers + 1 " + "WHERE user_id = ?") + twitter.pl_utils._execute_many_db_command(user_update_query2, + user_update2, + commit=True) + + # generate_log.info('twitter followee update finished.') + + post_insert_query = ( + "INSERT INTO post (user_id, content, created_at, num_likes, " + "num_dislikes) VALUES (?, ?, ?, ?, ?)") + twitter.pl_utils._execute_many_db_command(post_insert_query, + post_list, + commit=True) + + # generate_log.info('twitter creat post finished.') + + return agent_graph + + +async def generate_controllable_agents( + channel: Channel, + control_user_num: int, +) -> tuple[AgentGraph, dict]: + agent_graph = AgentGraph() + agent_user_id_mapping = {} + for i in range(control_user_num): + user_info = UserInfo( + is_controllable=True, + profile={"other_info": { + "user_profile": "None" + }}, + recsys_type="reddit", + ) + # controllable的agent_id全都在llm agent的agent_id的前面 + agent = SocialAgent(agent_id=i, + user_info=user_info, + channel=channel, + agent_graph=agent_graph) + # Add agent to the agent graph + agent_graph.add_agent(agent) + + username = input(f"Please input username for agent {i}: ") + name = input(f"Please input name for agent {i}: ") + bio = input(f"Please input bio for agent {i}: ") + + response = await agent.env.action.sign_up(username, name, bio) + user_id = response["user_id"] + agent_user_id_mapping[i] = user_id + + for i in range(control_user_num): + for j in range(control_user_num): + agent = agent_graph.get_agent(i) + # controllable agent互相也全部关注 + if i != j: + user_id = agent_user_id_mapping[j] + await agent.env.action.follow(user_id) + agent_graph.add_edge(i, j) + return agent_graph, agent_user_id_mapping + + +async def gen_control_agents_with_data( + channel: Channel, + control_user_num: int, + models: list[BaseModelBackend] | None = None, +) -> tuple[AgentGraph, dict]: + agent_graph = AgentGraph() + agent_user_id_mapping = {} + for i in range(control_user_num): + user_info = UserInfo( + is_controllable=True, + profile={ + "other_info": { + "user_profile": "None", + "gender": "None", + "mbti": "None", + "country": "None", + "age": "None", + } + }, + recsys_type="reddit", + ) + # controllable的agent_id全都在llm agent的agent_id的前面 + agent = SocialAgent( + agent_id=i, + user_info=user_info, + channel=channel, + agent_graph=agent_graph, + model=models, + available_actions=None, + ) + # Add agent to the agent graph + agent_graph.add_agent(agent) + user_name = "momo" + name = "momo" + bio = "None." + response = await agent.env.action.sign_up(user_name, name, bio) + user_id = response["user_id"] + agent_user_id_mapping[i] = user_id + + return agent_graph, agent_user_id_mapping + + +async def generate_reddit_agents( + agent_info_path: str, + channel: Channel, + agent_graph: AgentGraph | None = None, + agent_user_id_mapping: dict[int, int] | None = None, + follow_post_agent: bool = False, + mute_post_agent: bool = False, + model: Optional[Union[BaseModelBackend, List[BaseModelBackend], + ModelManager]] = None, + available_actions: list[ActionType] = None, +) -> AgentGraph: + if agent_user_id_mapping is None: + agent_user_id_mapping = {} + if agent_graph is None: + agent_graph = AgentGraph() + + control_user_num = agent_graph.get_num_nodes() + + with open(agent_info_path, "r") as file: + agent_info = json.load(file) + + async def process_agent(i): + # Instantiate an agent + profile = { + "nodes": [], # Relationships with other agents + "edges": [], # Relationship details + "other_info": {}, + } + # Update agent profile with additional information + profile["other_info"]["user_profile"] = agent_info[i]["persona"] + profile["other_info"]["mbti"] = agent_info[i]["mbti"] + profile["other_info"]["gender"] = agent_info[i]["gender"] + profile["other_info"]["age"] = agent_info[i]["age"] + profile["other_info"]["country"] = agent_info[i]["country"] + + user_info = UserInfo( + name=agent_info[i]["username"], + description=agent_info[i]["bio"], + profile=profile, + recsys_type="reddit", + ) + + agent = SocialAgent( + agent_id=i + control_user_num, + user_info=user_info, + channel=channel, + agent_graph=agent_graph, + model=model, + available_actions=available_actions, + ) + + # Add agent to the agent graph + agent_graph.add_agent(agent) + + # Sign up agent and add their information to the database + # print(f"Signing up agent {agent_info['username'][i]}...") + response = await agent.env.action.sign_up(agent_info[i]["username"], + agent_info[i]["realname"], + agent_info[i]["bio"]) + user_id = response["user_id"] + agent_user_id_mapping[i + control_user_num] = user_id + + if follow_post_agent: + await agent.env.action.follow(1) + content = """ +{ + "reason": "He is my friend, and I would like to follow him " + "on social media.", + "functions": [ + { + "name": "follow", + "arguments": { + "user_id": 1 + } + } + ] +} +""" + + agent_msg = BaseMessage.make_assistant_message( + role_name="Assistant", content=content) + agent.memory.write_record( + MemoryRecord(agent_msg, OpenAIBackendRole.ASSISTANT)) + elif mute_post_agent: + await agent.env.action.mute(1) + content = """ +{ + "reason": "He is my enemy, and I would like to mute him on social media.", + "functions": [{ + "name": "mute", + "arguments": { + "user_id": 1 + } +} +""" + agent_msg = BaseMessage.make_assistant_message( + role_name="Assistant", content=content) + agent.memory.write_record( + MemoryRecord(agent_msg, OpenAIBackendRole.ASSISTANT)) + + tasks = [process_agent(i) for i in range(len(agent_info))] + await asyncio.gather(*tasks) + + return agent_graph + + +def connect_platform_channel( + channel: Channel, + agent_graph: AgentGraph | None = None, +) -> AgentGraph: + for _, agent in agent_graph.get_agents(): + agent.channel = channel + agent.env.action.channel = channel + return agent_graph + + +async def generate_custom_agents( + channel: Channel, + agent_graph: AgentGraph | None = None, +) -> AgentGraph: + if agent_graph is None: + agent_graph = AgentGraph() + + agent_graph = connect_platform_channel(channel=channel, + agent_graph=agent_graph) + + sign_up_tasks = [ + agent.env.action.sign_up(user_name=agent.user_info.user_name, + name=agent.user_info.name, + bio=agent.user_info.description) + for _, agent in agent_graph.get_agents() + ] + await asyncio.gather(*sign_up_tasks) + return agent_graph + + +async def generate_reddit_agent_graph( + profile_path: str, + model: Optional[Union[BaseModelBackend, List[BaseModelBackend], + ModelManager]] = None, + available_actions: list[ActionType] = None, +) -> AgentGraph: + agent_graph = AgentGraph() + with open(profile_path, "r") as file: + agent_info = json.load(file) + + async def process_agent(i): + # Instantiate an agent + profile = { + "nodes": [], # Relationships with other agents + "edges": [], # Relationship details + "other_info": {}, + } + # Update agent profile with additional information + profile["other_info"]["user_profile"] = agent_info[i]["persona"] + profile["other_info"]["mbti"] = agent_info[i]["mbti"] + profile["other_info"]["gender"] = agent_info[i]["gender"] + profile["other_info"]["age"] = agent_info[i]["age"] + profile["other_info"]["country"] = agent_info[i]["country"] + + user_info = UserInfo( + name=agent_info[i]["username"], + description=agent_info[i]["bio"], + profile=profile, + recsys_type="reddit", + ) + + agent = SocialAgent( + agent_id=i, + user_info=user_info, + agent_graph=agent_graph, + model=model, + available_actions=available_actions, + ) + + # Add agent to the agent graph + agent_graph.add_agent(agent) + + tasks = [process_agent(i) for i in range(len(agent_info))] + await asyncio.gather(*tasks) + return agent_graph + + +async def generate_twitter_agent_graph( + profile_path: str, + model: Optional[Union[BaseModelBackend, List[BaseModelBackend], + ModelManager]] = None, + available_actions: list[ActionType] = None, +) -> AgentGraph: + agent_info = pd.read_csv(profile_path) + + agent_graph = AgentGraph() + + for agent_id in range(len(agent_info)): + profile = { + "nodes": [], + "edges": [], + "other_info": {}, + } + profile["other_info"]["user_profile"] = agent_info["user_char"][ + agent_id] + + user_info = UserInfo( + name=agent_info["username"][agent_id], + description=agent_info["description"][agent_id], + profile=profile, + recsys_type='twitter', + ) + + agent = SocialAgent( + agent_id=agent_id, + user_info=user_info, + model=model, + agent_graph=agent_graph, + available_actions=available_actions, + ) + + agent_graph.add_agent(agent) + return agent_graph diff --git a/backend/oasis/social_platform/__init__.py b/backend/oasis/social_platform/__init__.py new file mode 100644 index 00000000..83c0aca4 --- /dev/null +++ b/backend/oasis/social_platform/__init__.py @@ -0,0 +1,20 @@ +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +# Licensed under the Apache License, Version 2.0 (the “License”); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an “AS IS” BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +from .channel import Channel +from .platform import Platform + +__all__ = [ + "Channel", + "Platform", +] diff --git a/backend/oasis/social_platform/channel.py b/backend/oasis/social_platform/channel.py new file mode 100644 index 00000000..7d29f053 --- /dev/null +++ b/backend/oasis/social_platform/channel.py @@ -0,0 +1,71 @@ +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +# Licensed under the Apache License, Version 2.0 (the “License”); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an “AS IS” BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +import asyncio +import uuid + + +class AsyncSafeDict: + + def __init__(self): + self.dict = {} + self.lock = asyncio.Lock() + + async def put(self, key, value): + async with self.lock: + self.dict[key] = value + + async def get(self, key, default=None): + async with self.lock: + return self.dict.get(key, default) + + async def pop(self, key, default=None): + async with self.lock: + return self.dict.pop(key, default) + + async def keys(self): + async with self.lock: + return list(self.dict.keys()) + + +class Channel: + + def __init__(self): + self.receive_queue = asyncio.Queue() # Used to store received messages + # Using an asynchronous safe dictionary to store messages to be sent + self.send_dict = AsyncSafeDict() + + async def receive_from(self): + message = await self.receive_queue.get() + return message + + async def send_to(self, message): + # message_id is the first element of the message + message_id = message[0] + await self.send_dict.put(message_id, message) + + async def write_to_receive_queue(self, action_info): + message_id = str(uuid.uuid4()) + await self.receive_queue.put((message_id, action_info)) + return message_id + + async def read_from_send_queue(self, message_id): + while True: + if message_id in await self.send_dict.keys(): + # Attempting to retrieve the message + message = await self.send_dict.pop(message_id, None) + if message: + return message # Return the found message + # Temporarily suspend to avoid tight looping + await asyncio.sleep( + 0.1) # set a large one to reduce the workload of cpu diff --git a/backend/oasis/social_platform/config/__init__.py b/backend/oasis/social_platform/config/__init__.py new file mode 100644 index 00000000..8bfb9b4d --- /dev/null +++ b/backend/oasis/social_platform/config/__init__.py @@ -0,0 +1,20 @@ +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +# Licensed under the Apache License, Version 2.0 (the “License”); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an “AS IS” BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +from .neo4j import Neo4jConfig +from .user import UserInfo + +__all__ = [ + "UserInfo", + "Neo4jConfig", +] diff --git a/backend/oasis/social_platform/config/neo4j.py b/backend/oasis/social_platform/config/neo4j.py new file mode 100644 index 00000000..af5aacd5 --- /dev/null +++ b/backend/oasis/social_platform/config/neo4j.py @@ -0,0 +1,24 @@ +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +# Licensed under the Apache License, Version 2.0 (the “License”); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an “AS IS” BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +from dataclasses import dataclass + + +@dataclass +class Neo4jConfig: + uri: str | None = None + username: str | None = None + password: str | None = None + + def is_valid(self) -> bool: + return all([self.uri, self.username, self.password]) diff --git a/backend/oasis/social_platform/config/user.py b/backend/oasis/social_platform/config/user.py new file mode 100644 index 00000000..a36b4253 --- /dev/null +++ b/backend/oasis/social_platform/config/user.py @@ -0,0 +1,111 @@ +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +# Licensed under the Apache License, Version 2.0 (the “License”); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an “AS IS” BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +# flake8: noqa: E501 +import warnings +from dataclasses import dataclass +from typing import Any + +from camel.prompts import TextPrompt + + +@dataclass +class UserInfo: + user_name: str | None = None + name: str | None = None + description: str | None = None + profile: dict[str, Any] | None = None + recsys_type: str = "twitter" + is_controllable: bool = False + + def to_custom_system_message(self, user_info_template: TextPrompt) -> str: + required_keys = user_info_template.key_words + info_keys = set(self.profile.keys()) + missing = required_keys - info_keys + extra = info_keys - required_keys + if missing: + raise ValueError( + f"Missing required keys in UserInfo.profile: {missing}") + if extra: + warnings.warn(f"Extra keys not used in UserInfo.profile: {extra}") + + return user_info_template.format(**self.profile) + + def to_system_message(self) -> str: + if self.recsys_type != "reddit": + return self.to_twitter_system_message() + else: + return self.to_reddit_system_message() + + def to_twitter_system_message(self) -> str: + name_string = "" + description_string = "" + if self.name is not None: + name_string = f"Your name is {self.name}." + if self.profile is None: + description = name_string + elif "other_info" not in self.profile: + description = name_string + elif "user_profile" in self.profile["other_info"]: + if self.profile["other_info"]["user_profile"] is not None: + user_profile = self.profile["other_info"]["user_profile"] + description_string = f"Your have profile: {user_profile}." + description = f"{name_string}\n{description_string}" + + system_content = f""" +# OBJECTIVE +You're a Twitter user, and I'll present you with some posts. After you see the posts, choose some actions from the following functions. + +# SELF-DESCRIPTION +Your actions should be consistent with your self-description and personality. +{description} + +# RESPONSE METHOD +Please perform actions by tool calling. + """ + + return system_content + + def to_reddit_system_message(self) -> str: + name_string = "" + description_string = "" + if self.name is not None: + name_string = f"Your name is {self.name}." + if self.profile is None: + description = name_string + elif "other_info" not in self.profile: + description = name_string + elif "user_profile" in self.profile["other_info"]: + if self.profile["other_info"]["user_profile"] is not None: + user_profile = self.profile["other_info"]["user_profile"] + description_string = f"Your have profile: {user_profile}." + description = f"{name_string}\n{description_string}" + print(self.profile['other_info']) + description += ( + f"You are a {self.profile['other_info']['gender']}, " + f"{self.profile['other_info']['age']} years old, with an MBTI " + f"personality type of {self.profile['other_info']['mbti']} from " + f"{self.profile['other_info']['country']}.") + + system_content = f""" +# OBJECTIVE +You're a Reddit user, and I'll present you with some tweets. After you see the tweets, choose some actions from the following functions. + +# SELF-DESCRIPTION +Your actions should be consistent with your self-description and personality. +{description} + +# RESPONSE METHOD +Please perform actions by tool calling. +""" + return system_content diff --git a/backend/oasis/social_platform/database.py b/backend/oasis/social_platform/database.py new file mode 100644 index 00000000..4d8f83a8 --- /dev/null +++ b/backend/oasis/social_platform/database.py @@ -0,0 +1,291 @@ +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +# Licensed under the Apache License, Version 2.0 (the “License”); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an “AS IS” BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +from __future__ import annotations + +import os +import os.path as osp +import sqlite3 +from typing import Any, Dict, List + +SCHEMA_DIR = "social_platform/schema" +DB_DIR = "data" +DB_NAME = "social_media.db" + +USER_SCHEMA_SQL = "user.sql" +POST_SCHEMA_SQL = "post.sql" +FOLLOW_SCHEMA_SQL = "follow.sql" +MUTE_SCHEMA_SQL = "mute.sql" +LIKE_SCHEMA_SQL = "like.sql" +DISLIKE_SCHEMA_SQL = "dislike.sql" +REPORT_SCHEAM_SQL = "report.sql" +TRACE_SCHEMA_SQL = "trace.sql" +REC_SCHEMA_SQL = "rec.sql" +COMMENT_SCHEMA_SQL = "comment.sql" +COMMENT_LIKE_SCHEMA_SQL = "comment_like.sql" +COMMENT_DISLIKE_SCHEMA_SQL = "comment_dislike.sql" +PRODUCT_SCHEMA_SQL = "product.sql" +GROUP_SCHEMA_SQL = "chat_group.sql" +GROUP_MEMBER_SCHEMA_SQL = "group_member.sql" +GROUP_MESSAGE_SCHEMA_SQL = "group_message.sql" + +TABLE_NAMES = { + "user", + "post", + "follow", + "mute", + "like", + "dislike", + "report", + "trace", + "rec", + "comment.sql", + "comment_like.sql", + "comment_dislike.sql", + "product.sql", + "group", + "group_member", + "group_message", +} + + +def get_db_path() -> str: + # First check if the database path is set in environment variables + env_db_path = os.environ.get("OASIS_DB_PATH") + if env_db_path: + return env_db_path + + # If no environment variable is set, use the original default path + curr_file_path = osp.abspath(__file__) + parent_dir = osp.dirname(osp.dirname(curr_file_path)) + db_dir = osp.join(parent_dir, DB_DIR) + os.makedirs(db_dir, exist_ok=True) + db_path = osp.join(db_dir, DB_NAME) + return db_path + + +def get_schema_dir_path() -> str: + curr_file_path = osp.abspath(__file__) + parent_dir = osp.dirname(osp.dirname(curr_file_path)) + schema_dir = osp.join(parent_dir, SCHEMA_DIR) + return schema_dir + + +def create_db(db_path: str | None = None): + r"""Create the database if it does not exist. A :obj:`twitter.db` + file will be automatically created in the :obj:`data` directory. + """ + schema_dir = get_schema_dir_path() + if db_path is None: + db_path = get_db_path() + + # Connect to the database: + print("db_path", db_path) + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + try: + # Read and execute the user table SQL script: + user_sql_path = osp.join(schema_dir, USER_SCHEMA_SQL) + with open(user_sql_path, "r") as sql_file: + user_sql_script = sql_file.read() + cursor.executescript(user_sql_script) + + # Read and execute the post table SQL script: + post_sql_path = osp.join(schema_dir, POST_SCHEMA_SQL) + with open(post_sql_path, "r") as sql_file: + post_sql_script = sql_file.read() + cursor.executescript(post_sql_script) + + # Read and execute the follow table SQL script: + follow_sql_path = osp.join(schema_dir, FOLLOW_SCHEMA_SQL) + with open(follow_sql_path, "r") as sql_file: + follow_sql_script = sql_file.read() + cursor.executescript(follow_sql_script) + + # Read and execute the mute table SQL script: + mute_sql_path = osp.join(schema_dir, MUTE_SCHEMA_SQL) + with open(mute_sql_path, "r") as sql_file: + mute_sql_script = sql_file.read() + cursor.executescript(mute_sql_script) + + # Read and execute the like table SQL script: + like_sql_path = osp.join(schema_dir, LIKE_SCHEMA_SQL) + with open(like_sql_path, "r") as sql_file: + like_sql_script = sql_file.read() + cursor.executescript(like_sql_script) + + # Read and execute the dislike table SQL script: + dislike_sql_path = osp.join(schema_dir, DISLIKE_SCHEMA_SQL) + with open(dislike_sql_path, "r") as sql_file: + dislike_sql_script = sql_file.read() + cursor.executescript(dislike_sql_script) + + # Read and execute the report table SQL script: + report_sql_path = osp.join(schema_dir, REPORT_SCHEAM_SQL) + with open(report_sql_path, "r") as sql_file: + report_sql_script = sql_file.read() + cursor.executescript(report_sql_script) + + # Read and execute the trace table SQL script: + trace_sql_path = osp.join(schema_dir, TRACE_SCHEMA_SQL) + with open(trace_sql_path, "r") as sql_file: + trace_sql_script = sql_file.read() + cursor.executescript(trace_sql_script) + + # Read and execute the rec table SQL script: + rec_sql_path = osp.join(schema_dir, REC_SCHEMA_SQL) + with open(rec_sql_path, "r") as sql_file: + rec_sql_script = sql_file.read() + cursor.executescript(rec_sql_script) + + # Read and execute the comment table SQL script: + comment_sql_path = osp.join(schema_dir, COMMENT_SCHEMA_SQL) + with open(comment_sql_path, "r") as sql_file: + comment_sql_script = sql_file.read() + cursor.executescript(comment_sql_script) + + # Read and execute the comment_like table SQL script: + comment_like_sql_path = osp.join(schema_dir, COMMENT_LIKE_SCHEMA_SQL) + with open(comment_like_sql_path, "r") as sql_file: + comment_like_sql_script = sql_file.read() + cursor.executescript(comment_like_sql_script) + + # Read and execute the comment_dislike table SQL script: + comment_dislike_sql_path = osp.join(schema_dir, + COMMENT_DISLIKE_SCHEMA_SQL) + with open(comment_dislike_sql_path, "r") as sql_file: + comment_dislike_sql_script = sql_file.read() + cursor.executescript(comment_dislike_sql_script) + + # Read and execute the product table SQL script: + product_sql_path = osp.join(schema_dir, PRODUCT_SCHEMA_SQL) + with open(product_sql_path, "r") as sql_file: + product_sql_script = sql_file.read() + cursor.executescript(product_sql_script) + + # Read and execute the group table SQL script: + group_sql_path = osp.join(schema_dir, GROUP_SCHEMA_SQL) + with open(group_sql_path, "r") as sql_file: + group_sql_script = sql_file.read() + cursor.executescript(group_sql_script) + + # Read and execute the group_member table SQL script: + group_member_sql_path = osp.join(schema_dir, GROUP_MEMBER_SCHEMA_SQL) + with open(group_member_sql_path, "r") as sql_file: + group_member_sql_script = sql_file.read() + cursor.executescript(group_member_sql_script) + + # Read and execute the group_message table SQL script: + group_message_sql_path = osp.join(schema_dir, GROUP_MESSAGE_SCHEMA_SQL) + with open(group_message_sql_path, "r") as sql_file: + group_message_sql_script = sql_file.read() + cursor.executescript(group_message_sql_script) + + # Commit the changes: + conn.commit() + + except sqlite3.Error as e: + print(f"An error occurred while creating tables: {e}") + + return conn, cursor + + +def print_db_tables_summary(): + # Connect to the SQLite database + db_path = get_db_path() + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + # Retrieve a list of all tables in the database + cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") + tables = cursor.fetchall() + + # Print a summary of each table + for table in tables: + table_name = table[0] + if table_name not in TABLE_NAMES: + continue + print(f"Table: {table_name}") + + # Retrieve the table schema + cursor.execute(f"PRAGMA table_info({table_name})") + columns = cursor.fetchall() + column_names = [column[1] for column in columns] + print("- Columns:", column_names) + + # Retrieve and print foreign key information + cursor.execute(f"PRAGMA foreign_key_list({table_name})") + foreign_keys = cursor.fetchall() + if foreign_keys: + print("- Foreign Keys:") + for fk in foreign_keys: + print(f" {fk[2]} references {fk[3]}({fk[4]}) on update " + f"{fk[5]} on delete {fk[6]}") + else: + print(" No foreign keys.") + + # Print the first few rows of the table + cursor.execute(f"SELECT * FROM {table_name} LIMIT 5;") + rows = cursor.fetchall() + for row in rows: + print(row) + print() # Adds a newline for better readability between tables + + # Close the database connection + conn.close() + + +def fetch_table_from_db(cursor: sqlite3.Cursor, + table_name: str) -> List[Dict[str, Any]]: + cursor.execute(f"SELECT * FROM {table_name}") + columns = [description[0] for description in cursor.description] + data_dicts = [dict(zip(columns, row)) for row in cursor.fetchall()] + return data_dicts + + +def fetch_rec_table_as_matrix(cursor: sqlite3.Cursor) -> List[List[int]]: + # First, query all user_ids from the user table, assuming they start from + # 1 and are consecutive + cursor.execute("SELECT user_id FROM user ORDER BY user_id") + user_ids = [row[0] for row in cursor.fetchall()] + + # Then, query all records from the rec table + cursor.execute( + "SELECT user_id, post_id FROM rec ORDER BY user_id, post_id") + rec_rows = cursor.fetchall() + # Initialize a dictionary, assigning an empty list to each user_id + user_posts = {user_id: [] for user_id in user_ids} + # Fill the dictionary with the records queried from the rec table + for user_id, post_id in rec_rows: + if user_id in user_posts: + user_posts[user_id].append(post_id) + # Convert the dictionary into matrix form + matrix = [user_posts[user_id] for user_id in user_ids] + return matrix + + +def insert_matrix_into_rec_table(cursor: sqlite3.Cursor, + matrix: List[List[int]]) -> None: + # Iterate through the matrix, skipping the placeholder at index 0 + for user_id, post_ids in enumerate(matrix, start=1): + # Adjusted to start counting from 1 + for post_id in post_ids: + # Insert each combination of user_id and post_id into the rec table + cursor.execute("INSERT INTO rec (user_id, post_id) VALUES (?, ?)", + (user_id, post_id)) + + +if __name__ == "__main__": + create_db() + print_db_tables_summary() diff --git a/backend/oasis/social_platform/platform.py b/backend/oasis/social_platform/platform.py new file mode 100644 index 00000000..672e4bbb --- /dev/null +++ b/backend/oasis/social_platform/platform.py @@ -0,0 +1,1642 @@ +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +# Licensed under the Apache License, Version 2.0 (the “License”); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an “AS IS” BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +from __future__ import annotations + +import asyncio +import logging +import os +import random +import sqlite3 +import sys +from datetime import datetime, timedelta +from typing import Any + +from oasis.clock.clock import Clock +from oasis.social_platform.channel import Channel +from oasis.social_platform.database import (create_db, + fetch_rec_table_as_matrix, + fetch_table_from_db) +from oasis.social_platform.platform_utils import PlatformUtils +from oasis.social_platform.recsys import (rec_sys_personalized_twh, + rec_sys_personalized_with_trace, + rec_sys_random, rec_sys_reddit) +from oasis.social_platform.typing import ActionType, RecsysType + +# Create log directory if it doesn't exist +log_dir = "./log" +if not os.path.exists(log_dir): + os.makedirs(log_dir) + +if "sphinx" not in sys.modules: + twitter_log = logging.getLogger(name="social.twitter") + twitter_log.setLevel("DEBUG") + now = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + file_handler = logging.FileHandler(f"./log/social.twitter-{now}.log") + file_handler.setLevel("DEBUG") + file_handler.setFormatter( + logging.Formatter( + "%(levelname)s - %(asctime)s - %(name)s - %(message)s")) + twitter_log.addHandler(file_handler) + + +class Platform: + r"""Platform.""" + + def __init__( + self, + db_path: str, + channel: Any = None, + sandbox_clock: Clock | None = None, + start_time: datetime | None = None, + show_score: bool = False, + allow_self_rating: bool = True, + recsys_type: str | RecsysType = "reddit", + refresh_rec_post_count: int = 1, + max_rec_post_len: int = 2, + following_post_count=3, + use_openai_embedding: bool = False, + ): + self.db_path = db_path + self.recsys_type = recsys_type + # import pdb; pdb.set_trace() + + # If no clock is specified, default the platform's time + # magnification factor to 60 + if sandbox_clock is None: + sandbox_clock = Clock(60) + if start_time is None: + start_time = datetime.now() + self.start_time = start_time + self.sandbox_clock = sandbox_clock + + self.db, self.db_cursor = create_db(self.db_path) + self.db.execute("PRAGMA synchronous = OFF") + + self.channel = channel or Channel() + + self.recsys_type = RecsysType(recsys_type) + + # Whether to simulate showing scores like Reddit (likes minus dislikes) + # instead of showing likes and dislikes separately + self.show_score = show_score + + # Whether to allow users to like or dislike their own posts and + # comments + self.allow_self_rating = allow_self_rating + + # The number of posts returned by the social media internal + # recommendation system per refresh + self.refresh_rec_post_count = refresh_rec_post_count + # The number of posts returned at once from posts made by followed + # users, ranked by like counts + self.following_post_count = following_post_count + # The maximum number of posts per user in the recommendation + # table (buffer) + self.max_rec_post_len = max_rec_post_len + # rec prob between random and personalized + self.rec_prob = 0.7 + self.use_openai_embedding = use_openai_embedding + + # Parameters for the platform's internal trending rules + self.trend_num_days = 7 + self.trend_top_k = 1 + + # Report threshold setting + self.report_threshold = 2 + + self.pl_utils = PlatformUtils( + self.db, + self.db_cursor, + self.start_time, + self.sandbox_clock, + self.show_score, + self.recsys_type, + self.report_threshold, + ) + + async def running(self): + while True: + message_id, data = await self.channel.receive_from() + + agent_id, message, action = data + action = ActionType(action) + + if action == ActionType.EXIT: + # If the database is in-memory, save it to a file before + # losing + if self.db_path == ":memory:": + dst = sqlite3.connect("mock.db") + with dst: + self.db.backup(dst) + + self.db_cursor.close() + self.db.close() + break + + # Retrieve the corresponding function using getattr + action_function = getattr(self, action.value, None) + if action_function: + # Get the names of the parameters of the function + func_code = action_function.__code__ + param_names = func_code.co_varnames[:func_code.co_argcount] + + len_param_names = len(param_names) + if len_param_names > 3: + raise ValueError( + f"Functions with {len_param_names} parameters are not " + f"supported.") + # Build a dictionary of parameters + params = {} + if len_param_names >= 2: + params["agent_id"] = agent_id + if len_param_names == 3: + # Assuming the second element in param_names is the name + # of the second parameter you want to add + second_param_name = param_names[2] + params[second_param_name] = message + + # Call the function with the parameters + result = await action_function(**params) + await self.channel.send_to((message_id, agent_id, result)) + else: + raise ValueError(f"Action {action} is not supported") + + def run(self): + asyncio.run(self.running()) + + async def sign_up(self, agent_id, user_message): + user_name, name, bio = user_message + if self.recsys_type == RecsysType.REDDIT: + current_time = self.sandbox_clock.time_transfer( + datetime.now(), self.start_time) + else: + current_time = self.sandbox_clock.get_time_step() + try: + user_insert_query = ( + "INSERT INTO user (user_id, agent_id, user_name, name, " + "bio, created_at, num_followings, num_followers) VALUES " + "(?, ?, ?, ?, ?, ?, ?, ?)") + self.pl_utils._execute_db_command( + user_insert_query, + (agent_id, agent_id, user_name, name, bio, current_time, 0, 0), + commit=True, + ) + user_id = agent_id + + action_info = {"name": name, "user_name": user_name, "bio": bio} + self.pl_utils._record_trace(user_id, ActionType.SIGNUP.value, + action_info, current_time) + # twitter_log.info(f"Trace inserted: user_id={user_id}, " + # f"current_time={current_time}, " + # f"action={ActionType.SIGNUP.value}, " + # f"info={action_info}") + return {"success": True, "user_id": user_id} + except Exception as e: + return {"success": False, "error": str(e)} + + async def sign_up_product(self, product_id: int, product_name: str): + # Note: do not sign up the product with the same product name + try: + product_insert_query = ( + "INSERT INTO product (product_id, product_name) VALUES (?, ?)") + self.pl_utils._execute_db_command(product_insert_query, + (product_id, product_name), + commit=True) + return {"success": True, "product_id": product_id} + except Exception as e: + return {"success": False, "error": str(e)} + + async def purchase_product(self, agent_id, purchase_message): + product_name, purchase_num = purchase_message + if self.recsys_type == RecsysType.REDDIT: + current_time = self.sandbox_clock.time_transfer( + datetime.now(), self.start_time) + else: + current_time = self.sandbox_clock.get_time_step() + # try: + user_id = agent_id + # Check if a like record already exists + product_check_query = ( + "SELECT * FROM 'product' WHERE product_name = ?") + self.pl_utils._execute_db_command(product_check_query, + (product_name, )) + check_result = self.db_cursor.fetchone() + if not check_result: + # Product not found + return {"success": False, "error": "No such product."} + else: + product_id = check_result[0] + + product_update_query = ( + "UPDATE product SET sales = sales + ? WHERE product_name = ?") + self.pl_utils._execute_db_command(product_update_query, + (purchase_num, product_name), + commit=True) + + # Record the action in the trace table + action_info = { + "product_name": product_name, + "purchase_num": purchase_num + } + self.pl_utils._record_trace(user_id, ActionType.PURCHASE_PRODUCT.value, + action_info, current_time) + return {"success": True, "product_id": product_id} + # except Exception as e: + # return {"success": False, "error": str(e)} + + async def refresh(self, agent_id: int): + # Retrieve posts for a specific id from the rec table + if self.recsys_type == RecsysType.REDDIT: + current_time = self.sandbox_clock.time_transfer( + datetime.now(), self.start_time) + else: + current_time = self.sandbox_clock.get_time_step() + try: + user_id = agent_id + # Retrieve all post_ids for a given user_id from the rec table + rec_query = "SELECT post_id FROM rec WHERE user_id = ?" + self.pl_utils._execute_db_command(rec_query, (user_id, )) + rec_results = self.db_cursor.fetchall() + + post_ids = [row[0] for row in rec_results] + selected_post_ids = post_ids + # If the number of post_ids >= self.refresh_rec_post_count, + # randomly select a specified number of post_ids + if len(selected_post_ids) >= self.refresh_rec_post_count: + selected_post_ids = random.sample(selected_post_ids, + self.refresh_rec_post_count) + + if self.recsys_type != RecsysType.REDDIT: + # Retrieve posts from following (in network) + # Modify the SQL query so that the refresh gets posts from + # people the user follows, sorted by the number of likes on + # Twitter + query_following_post = ( + "SELECT post.post_id, post.user_id, post.content, " + "post.created_at, post.num_likes FROM post " + "JOIN follow ON post.user_id = follow.followee_id " + "WHERE follow.follower_id = ? " + "ORDER BY post.num_likes DESC " + "LIMIT ?") + self.pl_utils._execute_db_command( + query_following_post, + ( + user_id, + self.following_post_count, + ), + ) + + following_posts = self.db_cursor.fetchall() + following_posts_ids = [row[0] for row in following_posts] + + selected_post_ids = following_posts_ids + selected_post_ids + selected_post_ids = list(set(selected_post_ids)) + + placeholders = ", ".join("?" for _ in selected_post_ids) + + post_query = ( + f"SELECT post_id, user_id, original_post_id, content, " + f"quote_content, created_at, num_likes, num_dislikes, " + f"num_shares FROM post WHERE post_id IN ({placeholders})") + self.pl_utils._execute_db_command(post_query, selected_post_ids) + results = self.db_cursor.fetchall() + if not results: + return {"success": False, "message": "No posts found."} + results_with_comments = self.pl_utils._add_comments_to_posts( + results) + + action_info = {"posts": results_with_comments} + # twitter_log.info(action_info) + self.pl_utils._record_trace(user_id, ActionType.REFRESH.value, + action_info, current_time) + + return {"success": True, "posts": results_with_comments} + except Exception as e: + return {"success": False, "error": str(e)} + + async def update_rec_table(self): + # Recsys(trace/user/post table), refresh rec table + twitter_log.info("Starting to refresh recommendation system cache...") + user_table = fetch_table_from_db(self.db_cursor, "user") + post_table = fetch_table_from_db(self.db_cursor, "post") + trace_table = fetch_table_from_db(self.db_cursor, "trace") + rec_matrix = fetch_rec_table_as_matrix(self.db_cursor) + + if self.recsys_type == RecsysType.RANDOM: + new_rec_matrix = rec_sys_random(post_table, rec_matrix, + self.max_rec_post_len) + elif self.recsys_type == RecsysType.TWITTER: + new_rec_matrix = rec_sys_personalized_with_trace( + user_table, post_table, trace_table, rec_matrix, + self.max_rec_post_len) + elif self.recsys_type == RecsysType.TWHIN: + try: + latest_post_time = post_table[-1]["created_at"] + second_latest_post_time = post_table[-2]["created_at"] if len( + post_table) > 1 else latest_post_time + post_query = """ + SELECT COUNT(*) + FROM post + WHERE created_at = ? OR created_at = ? + """ + self.pl_utils._execute_db_command( + post_query, (latest_post_time, second_latest_post_time)) + result = self.db_cursor.fetchone() + latest_post_count = result[0] + if not latest_post_count: + return { + "success": False, + "message": "Fail to get latest posts count" + } + new_rec_matrix = rec_sys_personalized_twh( + user_table, + post_table, + latest_post_count, + trace_table, + rec_matrix, + self.max_rec_post_len, + self.sandbox_clock.time_step, + use_openai_embedding=self.use_openai_embedding, + ) + except Exception as e: + twitter_log.error(e) + # If no post in the platform, skip updating the rec table + return + elif self.recsys_type == RecsysType.REDDIT: + new_rec_matrix = rec_sys_reddit(post_table, rec_matrix, + self.max_rec_post_len) + else: + raise ValueError("Unsupported recommendation system type, please " + "check the `RecsysType`.") + + sql_query = "DELETE FROM rec" + # Execute the SQL statement using the _execute_db_command function + self.pl_utils._execute_db_command(sql_query, commit=True) + + # Batch insertion is more time-efficient + # create a list of values to insert + insert_values = [(user_id, post_id) + for user_id in range(len(new_rec_matrix)) + for post_id in new_rec_matrix[user_id]] + + # Perform batch insertion into the database + self.pl_utils._execute_many_db_command( + "INSERT INTO rec (user_id, post_id) VALUES (?, ?)", + insert_values, + commit=True, + ) + + async def create_post(self, agent_id: int, content: str): + if self.recsys_type == RecsysType.REDDIT: + current_time = self.sandbox_clock.time_transfer( + datetime.now(), self.start_time) + else: + current_time = self.sandbox_clock.get_time_step() + try: + user_id = agent_id + + post_insert_query = ( + "INSERT INTO post (user_id, content, created_at, num_likes, " + "num_dislikes, num_shares) VALUES (?, ?, ?, ?, ?, ?)") + self.pl_utils._execute_db_command( + post_insert_query, (user_id, content, current_time, 0, 0, 0), + commit=True) + post_id = self.db_cursor.lastrowid + + action_info = {"content": content, "post_id": post_id} + self.pl_utils._record_trace(user_id, ActionType.CREATE_POST.value, + action_info, current_time) + + # twitter_log.info(f"Trace inserted: user_id={user_id}, " + # f"current_time={current_time}, " + # f"action={ActionType.CREATE_POST.value}, " + # f"info={action_info}") + return {"success": True, "post_id": post_id} + + except Exception as e: + return {"success": False, "error": str(e)} + + async def repost(self, agent_id: int, post_id: int): + if self.recsys_type == RecsysType.REDDIT: + current_time = self.sandbox_clock.time_transfer( + datetime.now(), self.start_time) + else: + current_time = self.sandbox_clock.get_time_step() + try: + user_id = agent_id + + # Ensure the content has not been reposted by this user before + repost_check_query = ( + "SELECT * FROM 'post' WHERE original_post_id = ? AND " + "user_id = ?") + self.pl_utils._execute_db_command(repost_check_query, + (post_id, user_id)) + if self.db_cursor.fetchone(): + # for common and quote post, check if the post has been + # reposted + return { + "success": False, + "error": "Repost record already exists." + } + + post_type_result = self.pl_utils._get_post_type(post_id) + post_insert_query = ("INSERT INTO post (user_id, original_post_id" + ", created_at) VALUES (?, ?, ?)") + # Update num_shares for the found post + update_shares_query = ( + "UPDATE post SET num_shares = num_shares + 1 WHERE post_id = ?" + ) + + if not post_type_result: + return {"success": False, "error": "Post not found."} + elif (post_type_result['type'] == 'common' + or post_type_result['type'] == 'quote'): + self.pl_utils._execute_db_command( + post_insert_query, (user_id, post_id, current_time), + commit=True) + self.pl_utils._execute_db_command(update_shares_query, + (post_id, ), + commit=True) + elif post_type_result['type'] == 'repost': + repost_check_query = ( + "SELECT * FROM 'post' WHERE original_post_id = ? AND " + "user_id = ?") + self.pl_utils._execute_db_command( + repost_check_query, + (post_type_result['root_post_id'], user_id)) + + if self.db_cursor.fetchone(): + # for repost post, check if the post has been reposted + return { + "success": False, + "error": "Repost record already exists." + } + + self.pl_utils._execute_db_command( + post_insert_query, + (user_id, post_type_result['root_post_id'], current_time), + commit=True) + self.pl_utils._execute_db_command( + update_shares_query, (post_type_result['root_post_id'], ), + commit=True) + + new_post_id = self.db_cursor.lastrowid + + action_info = {"reposted_id": post_id, "new_post_id": new_post_id} + self.pl_utils._record_trace(user_id, ActionType.REPOST.value, + action_info, current_time) + + return {"success": True, "post_id": new_post_id} + except Exception as e: + return {"success": False, "error": str(e)} + + async def quote_post(self, agent_id: int, quote_message: tuple): + post_id, quote_content = quote_message + if self.recsys_type == RecsysType.REDDIT: + current_time = self.sandbox_clock.time_transfer( + datetime.now(), self.start_time) + else: + current_time = self.sandbox_clock.get_time_step() + try: + user_id = agent_id + + # Allow quote a post more than once because the quote content may + # be different + + post_query = "SELECT content FROM post WHERE post_id = ?" + + post_type_result = self.pl_utils._get_post_type(post_id) + post_insert_query = ( + "INSERT INTO post (user_id, original_post_id, " + "content, quote_content, created_at) VALUES (?, ?, ?, ?, ?)") + update_shares_query = ( + "UPDATE post SET num_shares = num_shares + 1 WHERE post_id = ?" + ) + + if not post_type_result: + return {"success": False, "error": "Post not found."} + elif post_type_result['type'] == 'common': + self.pl_utils._execute_db_command(post_query, (post_id, )) + post_content = self.db_cursor.fetchone()[0] + self.pl_utils._execute_db_command( + post_insert_query, (user_id, post_id, post_content, + quote_content, current_time), + commit=True) + self.pl_utils._execute_db_command(update_shares_query, + (post_id, ), + commit=True) + elif (post_type_result['type'] == 'repost' + or post_type_result['type'] == 'quote'): + self.pl_utils._execute_db_command( + post_query, (post_type_result['root_post_id'], )) + post_content = self.db_cursor.fetchone()[0] + self.pl_utils._execute_db_command( + post_insert_query, + (user_id, post_type_result['root_post_id'], post_content, + quote_content, current_time), + commit=True) + self.pl_utils._execute_db_command( + update_shares_query, (post_type_result['root_post_id'], ), + commit=True) + + new_post_id = self.db_cursor.lastrowid + + action_info = {"quoted_id": post_id, "new_post_id": new_post_id} + self.pl_utils._record_trace(user_id, ActionType.QUOTE_POST.value, + action_info, current_time) + + return {"success": True, "post_id": new_post_id} + except Exception as e: + return {"success": False, "error": str(e)} + + async def like_post(self, agent_id: int, post_id: int): + if self.recsys_type == RecsysType.REDDIT: + current_time = self.sandbox_clock.time_transfer( + datetime.now(), self.start_time) + else: + current_time = self.sandbox_clock.get_time_step() + try: + post_type_result = self.pl_utils._get_post_type(post_id) + if post_type_result['type'] == 'repost': + post_id = post_type_result['root_post_id'] + user_id = agent_id + # Check if a like record already exists + like_check_query = ("SELECT * FROM 'like' WHERE post_id = ? AND " + "user_id = ?") + self.pl_utils._execute_db_command(like_check_query, + (post_id, user_id)) + if self.db_cursor.fetchone(): + # Like record already exists + return { + "success": False, + "error": "Like record already exists." + } + + # Check if the post to be liked is self-posted + if self.allow_self_rating is False: + check_result = self.pl_utils._check_self_post_rating( + post_id, user_id) + if check_result: + return check_result + + # Update the number of likes in the post table + post_update_query = ( + "UPDATE post SET num_likes = num_likes + 1 WHERE post_id = ?") + self.pl_utils._execute_db_command(post_update_query, (post_id, ), + commit=True) + + # Add a record in the like table + like_insert_query = ( + "INSERT INTO 'like' (post_id, user_id, created_at) " + "VALUES (?, ?, ?)") + self.pl_utils._execute_db_command(like_insert_query, + (post_id, user_id, current_time), + commit=True) + # Get the ID of the newly inserted like record + like_id = self.db_cursor.lastrowid + + # Record the action in the trace table + # if post has been reposted, record the root post id into trace + action_info = {"post_id": post_id, "like_id": like_id} + self.pl_utils._record_trace(user_id, ActionType.LIKE_POST.value, + action_info, current_time) + return {"success": True, "like_id": like_id} + except Exception as e: + return {"success": False, "error": str(e)} + + async def unlike_post(self, agent_id: int, post_id: int): + try: + post_type_result = self.pl_utils._get_post_type(post_id) + if post_type_result['type'] == 'repost': + post_id = post_type_result['root_post_id'] + user_id = agent_id + + # Check if a like record already exists + like_check_query = ("SELECT * FROM 'like' WHERE post_id = ? AND " + "user_id = ?") + self.pl_utils._execute_db_command(like_check_query, + (post_id, user_id)) + result = self.db_cursor.fetchone() + + if not result: + # No like record exists + return { + "success": False, + "error": "Like record does not exist." + } + + # Get the `like_id` + like_id, _, _, _ = result + + # Update the number of likes in the post table + post_update_query = ( + "UPDATE post SET num_likes = num_likes - 1 WHERE post_id = ?") + self.pl_utils._execute_db_command( + post_update_query, + (post_id, ), + commit=True, + ) + + # Delete the record in the like table + like_delete_query = "DELETE FROM 'like' WHERE like_id = ?" + self.pl_utils._execute_db_command( + like_delete_query, + (like_id, ), + commit=True, + ) + + # Record the action in the trace table + action_info = {"post_id": post_id, "like_id": like_id} + self.pl_utils._record_trace(user_id, ActionType.UNLIKE_POST.value, + action_info) + return {"success": True, "like_id": like_id} + except Exception as e: + return {"success": False, "error": str(e)} + + async def dislike_post(self, agent_id: int, post_id: int): + if self.recsys_type == RecsysType.REDDIT: + current_time = self.sandbox_clock.time_transfer( + datetime.now(), self.start_time) + else: + current_time = self.sandbox_clock.get_time_step() + try: + post_type_result = self.pl_utils._get_post_type(post_id) + if post_type_result['type'] == 'repost': + post_id = post_type_result['root_post_id'] + user_id = agent_id + # Check if a dislike record already exists + like_check_query = ( + "SELECT * FROM 'dislike' WHERE post_id = ? AND user_id = ?") + self.pl_utils._execute_db_command(like_check_query, + (post_id, user_id)) + if self.db_cursor.fetchone(): + # Dislike record already exists + return { + "success": False, + "error": "Dislike record already exists." + } + + # Check if the post to be disliked is self-posted + if self.allow_self_rating is False: + check_result = self.pl_utils._check_self_post_rating( + post_id, user_id) + if check_result: + return check_result + + # Update the number of dislikes in the post table + post_update_query = ( + "UPDATE post SET num_dislikes = num_dislikes + 1 WHERE " + "post_id = ?") + self.pl_utils._execute_db_command(post_update_query, (post_id, ), + commit=True) + + # Add a record in the dislike table + dislike_insert_query = ( + "INSERT INTO 'dislike' (post_id, user_id, created_at) " + "VALUES (?, ?, ?)") + self.pl_utils._execute_db_command(dislike_insert_query, + (post_id, user_id, current_time), + commit=True) + # Get the ID of the newly inserted dislike record + dislike_id = self.db_cursor.lastrowid + + # Record the action in the trace table + action_info = {"post_id": post_id, "dislike_id": dislike_id} + self.pl_utils._record_trace(user_id, ActionType.DISLIKE_POST.value, + action_info, current_time) + return {"success": True, "dislike_id": dislike_id} + except Exception as e: + return {"success": False, "error": str(e)} + + async def undo_dislike_post(self, agent_id: int, post_id: int): + try: + post_type_result = self.pl_utils._get_post_type(post_id) + if post_type_result['type'] == 'repost': + post_id = post_type_result['root_post_id'] + user_id = agent_id + + # Check if a dislike record already exists + like_check_query = ( + "SELECT * FROM 'dislike' WHERE post_id = ? AND user_id = ?") + self.pl_utils._execute_db_command(like_check_query, + (post_id, user_id)) + result = self.db_cursor.fetchone() + + if not result: + # No dislike record exists + return { + "success": False, + "error": "Dislike record does not exist." + } + + # Get the `dislike_id` + dislike_id, _, _, _ = result + + # Update the number of dislikes in the post table + post_update_query = ( + "UPDATE post SET num_dislikes = num_dislikes - 1 WHERE " + "post_id = ?") + self.pl_utils._execute_db_command( + post_update_query, + (post_id, ), + commit=True, + ) + + # Delete the record in the dislike table + like_delete_query = "DELETE FROM 'dislike' WHERE dislike_id = ?" + self.pl_utils._execute_db_command( + like_delete_query, + (dislike_id, ), + commit=True, + ) + + # Record the action in the trace table + action_info = {"post_id": post_id, "dislike_id": dislike_id} + self.pl_utils._record_trace(user_id, + ActionType.UNDO_DISLIKE_POST.value, + action_info) + return {"success": True, "dislike_id": dislike_id} + except Exception as e: + return {"success": False, "error": str(e)} + + async def search_posts(self, agent_id: int, query: str): + try: + user_id = agent_id + # Update the SQL query to search by content, post_id, and user_id + # simultaneously + sql_query = ( + "SELECT post_id, user_id, original_post_id, content, " + "quote_content, created_at, num_likes, num_dislikes, " + "num_shares FROM post WHERE content LIKE ? OR CAST(post_id AS " + "TEXT) LIKE ? OR CAST(user_id AS TEXT) LIKE ?") + # Note: CAST is necessary because post_id and user_id are integers, + # while the search query is a string type + self.pl_utils._execute_db_command( + sql_query, + ("%" + query + "%", "%" + query + "%", "%" + query + "%"), + commit=True, + ) + results = self.db_cursor.fetchall() + + # Record the operation in the trace table + action_info = {"query": query} + self.pl_utils._record_trace(user_id, ActionType.SEARCH_POSTS.value, + action_info) + + # If no results are found, return a dictionary indicating failure + if not results: + return { + "success": False, + "message": "No posts found matching the query.", + } + results_with_comments = self.pl_utils._add_comments_to_posts( + results) + + return {"success": True, "posts": results_with_comments} + except Exception as e: + return {"success": False, "error": str(e)} + + async def search_user(self, agent_id: int, query: str): + try: + user_id = agent_id + sql_query = ( + "SELECT user_id, user_name, name, bio, created_at, " + "num_followings, num_followers " + "FROM user " + "WHERE user_name LIKE ? OR name LIKE ? OR bio LIKE ? OR " + "CAST(user_id AS TEXT) LIKE ?") + # Rewrite to use the execute_db_command method + self.pl_utils._execute_db_command( + sql_query, + ( + "%" + query + "%", + "%" + query + "%", + "%" + query + "%", + "%" + query + "%", + ), + commit=True, + ) + results = self.db_cursor.fetchall() + + # Record the operation in the trace table + action_info = {"query": query} + self.pl_utils._record_trace(user_id, ActionType.SEARCH_USER.value, + action_info) + + # If no results are found, return a dict indicating failure + if not results: + return { + "success": False, + "message": "No users found matching the query.", + } + + # Convert each tuple in results into a dictionary + users = [{ + "user_id": user_id, + "user_name": user_name, + "name": name, + "bio": bio, + "created_at": created_at, + "num_followings": num_followings, + "num_followers": num_followers, + } for user_id, user_name, name, bio, created_at, num_followings, + num_followers in results] + return {"success": True, "users": users} + except Exception as e: + return {"success": False, "error": str(e)} + + async def follow(self, agent_id: int, followee_id: int): + if self.recsys_type == RecsysType.REDDIT: + current_time = self.sandbox_clock.time_transfer( + datetime.now(), self.start_time) + else: + current_time = self.sandbox_clock.get_time_step() + try: + user_id = agent_id + # Check if a follow record already exists + follow_check_query = ("SELECT * FROM follow WHERE follower_id = ? " + "AND followee_id = ?") + self.pl_utils._execute_db_command(follow_check_query, + (user_id, followee_id)) + if self.db_cursor.fetchone(): + # Follow record already exists + return { + "success": False, + "error": "Follow record already exists." + } + + # Add a record in the follow table + follow_insert_query = ( + "INSERT INTO follow (follower_id, followee_id, created_at) " + "VALUES (?, ?, ?)") + self.pl_utils._execute_db_command( + follow_insert_query, (user_id, followee_id, current_time), + commit=True) + # Get the ID of the newly inserted follow record + follow_id = self.db_cursor.lastrowid + + # Update the following field in the user table + user_update_query1 = ( + "UPDATE user SET num_followings = num_followings + 1 " + "WHERE user_id = ?") + self.pl_utils._execute_db_command(user_update_query1, (user_id, ), + commit=True) + + # Update the follower field in the user table + user_update_query2 = ( + "UPDATE user SET num_followers = num_followers + 1 " + "WHERE user_id = ?") + self.pl_utils._execute_db_command(user_update_query2, + (followee_id, ), + commit=True) + + # Record the operation in the trace table + action_info = {"follow_id": follow_id} + self.pl_utils._record_trace(user_id, ActionType.FOLLOW.value, + action_info, current_time) + # twitter_log.info(f"Trace inserted: user_id={user_id}, " + # f"current_time={current_time}, " + # f"action={ActionType.FOLLOW.value}, " + # f"info={action_info}") + return {"success": True, "follow_id": follow_id} + except Exception as e: + return {"success": False, "error": str(e)} + + async def unfollow(self, agent_id: int, followee_id: int): + try: + user_id = agent_id + # Check for the existence of a follow record and get its ID + follow_check_query = ( + "SELECT follow_id FROM follow WHERE follower_id = ? AND " + "followee_id = ?") + self.pl_utils._execute_db_command(follow_check_query, + (user_id, followee_id)) + follow_record = self.db_cursor.fetchone() + if not follow_record: + return { + "success": False, + "error": "Follow record does not exist." + } + # Assuming ID is in the first column of the result + follow_id = follow_record[0] + + # Delete the record in the follow table + follow_delete_query = "DELETE FROM follow WHERE follow_id = ?" + self.pl_utils._execute_db_command(follow_delete_query, + (follow_id, ), + commit=True) + + # Update the following field in the user table + user_update_query1 = ( + "UPDATE user SET num_followings = num_followings - 1 " + "WHERE user_id = ?") + self.pl_utils._execute_db_command(user_update_query1, (user_id, ), + commit=True) + + # Update the follower field in the user table + user_update_query2 = ( + "UPDATE user SET num_followers = num_followers - 1 " + "WHERE user_id = ?") + self.pl_utils._execute_db_command(user_update_query2, + (followee_id, ), + commit=True) + + # Record the operation in the trace table + action_info = {"followee_id": followee_id} + self.pl_utils._record_trace(user_id, ActionType.UNFOLLOW.value, + action_info) + return { + "success": True, + "follow_id": follow_id, + } # Return the ID of the deleted follow record + except Exception as e: + return {"success": False, "error": str(e)} + + async def mute(self, agent_id: int, mutee_id: int): + if self.recsys_type == RecsysType.REDDIT: + current_time = self.sandbox_clock.time_transfer( + datetime.now(), self.start_time) + else: + current_time = self.sandbox_clock.get_time_step() + try: + user_id = agent_id + # Check if a mute record already exists + mute_check_query = ("SELECT * FROM mute WHERE muter_id = ? AND " + "mutee_id = ?") + self.pl_utils._execute_db_command(mute_check_query, + (user_id, mutee_id)) + if self.db_cursor.fetchone(): + # Mute record already exists + return { + "success": False, + "error": "Mute record already exists." + } + # Add a record in the mute table + mute_insert_query = ( + "INSERT INTO mute (muter_id, mutee_id, created_at) " + "VALUES (?, ?, ?)") + self.pl_utils._execute_db_command( + mute_insert_query, (user_id, mutee_id, current_time), + commit=True) + # Get the ID of the newly inserted mute record + mute_id = self.db_cursor.lastrowid + + # Record the operation in the trace table + action_info = {"mutee_id": mutee_id} + self.pl_utils._record_trace(user_id, ActionType.MUTE.value, + action_info, current_time) + return {"success": True, "mute_id": mute_id} + except Exception as e: + return {"success": False, "error": str(e)} + + async def unmute(self, agent_id: int, mutee_id: int): + try: + user_id = agent_id + # Check for the specified mute record and get mute_id + mute_check_query = ( + "SELECT mute_id FROM mute WHERE muter_id = ? AND mutee_id = ?") + self.pl_utils._execute_db_command(mute_check_query, + (user_id, mutee_id)) + mute_record = self.db_cursor.fetchone() + if not mute_record: + # If no mute record exists + return {"success": False, "error": "No mute record exists."} + mute_id = mute_record[0] + + # Delete the specified mute record from the mute table + mute_delete_query = "DELETE FROM mute WHERE mute_id = ?" + self.pl_utils._execute_db_command(mute_delete_query, (mute_id, ), + commit=True) + + # Record the unmute operation in the trace table + action_info = {"mutee_id": mutee_id} + self.pl_utils._record_trace(user_id, ActionType.UNMUTE.value, + action_info) + return {"success": True, "mute_id": mute_id} + except Exception as e: + return {"success": False, "error": str(e)} + + async def trend(self, agent_id: int): + """ + Get the top K trending posts in the last num_days days. + """ + if self.recsys_type == RecsysType.REDDIT: + current_time = self.sandbox_clock.time_transfer( + datetime.now(), self.start_time) + else: + current_time = self.sandbox_clock.get_time_step() + try: + user_id = agent_id + # Calculate the start time for the search + if self.recsys_type == RecsysType.REDDIT: + start_time = current_time - timedelta(days=self.trend_num_days) + else: + start_time = int(current_time) - self.trend_num_days * 24 * 60 + + # Build the SQL query + sql_query = """ + SELECT post_id, user_id, original_post_id, content, + quote_content, created_at, num_likes, num_dislikes, + num_shares FROM post + WHERE created_at >= ? + ORDER BY num_likes DESC + LIMIT ? + """ + # Execute the database query + self.pl_utils._execute_db_command(sql_query, + (start_time, self.trend_top_k), + commit=True) + results = self.db_cursor.fetchall() + + # If no results were found, return a dictionary indicating failure + if not results: + return { + "success": False, + "message": "No trending posts in the specified period.", + } + results_with_comments = self.pl_utils._add_comments_to_posts( + results) + + action_info = {"posts": results_with_comments} + self.pl_utils._record_trace(user_id, ActionType.TREND.value, + action_info, current_time) + + return {"success": True, "posts": results_with_comments} + except Exception as e: + return {"success": False, "error": str(e)} + + async def create_comment(self, agent_id: int, comment_message: tuple): + post_id, content = comment_message + if self.recsys_type == RecsysType.REDDIT: + current_time = self.sandbox_clock.time_transfer( + datetime.now(), self.start_time) + else: + current_time = self.sandbox_clock.get_time_step() + try: + post_type_result = self.pl_utils._get_post_type(post_id) + if post_type_result['type'] == 'repost': + post_id = post_type_result['root_post_id'] + user_id = agent_id + + # Insert the comment record + comment_insert_query = ( + "INSERT INTO comment (post_id, user_id, content, created_at) " + "VALUES (?, ?, ?, ?)") + self.pl_utils._execute_db_command( + comment_insert_query, + (post_id, user_id, content, current_time), + commit=True, + ) + comment_id = self.db_cursor.lastrowid + + # Prepare information for the trace record + action_info = {"content": content, "comment_id": comment_id} + self.pl_utils._record_trace(user_id, + ActionType.CREATE_COMMENT.value, + action_info, current_time) + + return {"success": True, "comment_id": comment_id} + except Exception as e: + return {"success": False, "error": str(e)} + + async def like_comment(self, agent_id: int, comment_id: int): + if self.recsys_type == RecsysType.REDDIT: + current_time = self.sandbox_clock.time_transfer( + datetime.now(), self.start_time) + else: + current_time = self.sandbox_clock.get_time_step() + try: + user_id = agent_id + + # Check if a like record already exists + like_check_query = ( + "SELECT * FROM comment_like WHERE comment_id = ? AND " + "user_id = ?") + self.pl_utils._execute_db_command(like_check_query, + (comment_id, user_id)) + if self.db_cursor.fetchone(): + # Like record already exists + return { + "success": False, + "error": "Comment like record already exists.", + } + + # Check if the comment to be liked was posted by oneself + if self.allow_self_rating is False: + check_result = self.pl_utils._check_self_comment_rating( + comment_id, user_id) + if check_result: + return check_result + + # Update the number of likes in the comment table + comment_update_query = ( + "UPDATE comment SET num_likes = num_likes + 1 WHERE " + "comment_id = ?") + self.pl_utils._execute_db_command(comment_update_query, + (comment_id, ), + commit=True) + + # Add a record in the comment_like table + like_insert_query = ( + "INSERT INTO comment_like (comment_id, user_id, created_at) " + "VALUES (?, ?, ?)") + self.pl_utils._execute_db_command( + like_insert_query, (comment_id, user_id, current_time), + commit=True) + # Get the ID of the newly inserted like record + comment_like_id = self.db_cursor.lastrowid + + # Record the operation in the trace table + action_info = { + "comment_id": comment_id, + "comment_like_id": comment_like_id + } + self.pl_utils._record_trace(user_id, ActionType.LIKE_COMMENT.value, + action_info, current_time) + return {"success": True, "comment_like_id": comment_like_id} + except Exception as e: + return {"success": False, "error": str(e)} + + async def unlike_comment(self, agent_id: int, comment_id: int): + try: + user_id = agent_id + + # Check if a like record already exists + like_check_query = ( + "SELECT * FROM comment_like WHERE comment_id = ? AND " + "user_id = ?") + self.pl_utils._execute_db_command(like_check_query, + (comment_id, user_id)) + result = self.db_cursor.fetchone() + + if not result: + # No like record exists + return { + "success": False, + "error": "Comment like record does not exist.", + } + # Get the `comment_like_id` + comment_like_id = result[0] + + # Update the number of likes in the comment table + comment_update_query = ( + "UPDATE comment SET num_likes = num_likes - 1 WHERE " + "comment_id = ?") + self.pl_utils._execute_db_command( + comment_update_query, + (comment_id, ), + commit=True, + ) + # Delete the record in the comment_like table + like_delete_query = ("DELETE FROM comment_like WHERE " + "comment_like_id = ?") + self.pl_utils._execute_db_command( + like_delete_query, + (comment_like_id, ), + commit=True, + ) + # Record the operation in the trace table + action_info = { + "comment_id": comment_id, + "comment_like_id": comment_like_id + } + self.pl_utils._record_trace(user_id, + ActionType.UNLIKE_COMMENT.value, + action_info) + return {"success": True, "comment_like_id": comment_like_id} + except Exception as e: + return {"success": False, "error": str(e)} + + async def dislike_comment(self, agent_id: int, comment_id: int): + if self.recsys_type == RecsysType.REDDIT: + current_time = self.sandbox_clock.time_transfer( + datetime.now(), self.start_time) + else: + current_time = self.sandbox_clock.get_time_step() + try: + user_id = agent_id + + # Check if a dislike record already exists + dislike_check_query = ( + "SELECT * FROM comment_dislike WHERE comment_id = ? AND " + "user_id = ?") + self.pl_utils._execute_db_command(dislike_check_query, + (comment_id, user_id)) + if self.db_cursor.fetchone(): + # Dislike record already exists + return { + "success": False, + "error": "Comment dislike record already exists.", + } + + # Check if the comment to be disliked was posted by oneself + if self.allow_self_rating is False: + check_result = self.pl_utils._check_self_comment_rating( + comment_id, user_id) + if check_result: + return check_result + + # Update the number of dislikes in the comment table + comment_update_query = ( + "UPDATE comment SET num_dislikes = num_dislikes + 1 WHERE " + "comment_id = ?") + self.pl_utils._execute_db_command(comment_update_query, + (comment_id, ), + commit=True) + + # Add a record in the comment_dislike table + dislike_insert_query = ( + "INSERT INTO comment_dislike (comment_id, user_id, " + "created_at) VALUES (?, ?, ?)") + self.pl_utils._execute_db_command( + dislike_insert_query, (comment_id, user_id, current_time), + commit=True) + # Get the ID of the newly inserted dislike record + comment_dislike_id = (self.db_cursor.lastrowid) + + # Record the operation in the trace table + action_info = { + "comment_id": comment_id, + "comment_dislike_id": comment_dislike_id, + } + self.pl_utils._record_trace(user_id, + ActionType.DISLIKE_COMMENT.value, + action_info, current_time) + return {"success": True, "comment_dislike_id": comment_dislike_id} + except Exception as e: + return {"success": False, "error": str(e)} + + async def undo_dislike_comment(self, agent_id: int, comment_id: int): + if self.recsys_type == RecsysType.REDDIT: + current_time = self.sandbox_clock.time_transfer( + datetime.now(), self.start_time) + else: + current_time = self.sandbox_clock.get_time_step() + try: + user_id = agent_id + + # Check if a dislike record already exists + dislike_check_query = ( + "SELECT comment_dislike_id FROM comment_dislike WHERE " + "comment_id = ? AND user_id = ?") + self.pl_utils._execute_db_command(dislike_check_query, + (comment_id, user_id)) + dislike_record = self.db_cursor.fetchone() + if not dislike_record: + # No dislike record exists + return { + "success": False, + "error": "Comment dislike record does not exist.", + } + comment_dislike_id = dislike_record[0] + + # Delete the record from the comment_dislike table + dislike_delete_query = ( + "DELETE FROM comment_dislike WHERE comment_id = ? AND " + "user_id = ?") + self.pl_utils._execute_db_command(dislike_delete_query, + (comment_id, user_id), + commit=True) + + # Update the number of dislikes in the comment table + comment_update_query = ( + "UPDATE comment SET num_dislikes = num_dislikes - 1 WHERE " + "comment_id = ?") + self.pl_utils._execute_db_command(comment_update_query, + (comment_id, ), + commit=True) + + # Record the operation in the trace table + action_info = { + "comment_id": comment_id, + "comment_dislike_id": comment_dislike_id, + } + self.pl_utils._record_trace(user_id, + ActionType.UNDO_DISLIKE_COMMENT.value, + action_info, current_time) + return {"success": True, "comment_dislike_id": comment_dislike_id} + except Exception as e: + return {"success": False, "error": str(e)} + + async def do_nothing(self, agent_id: int): + if self.recsys_type == RecsysType.REDDIT: + current_time = self.sandbox_clock.time_transfer( + datetime.now(), self.start_time) + else: + current_time = self.sandbox_clock.get_time_step() + try: + user_id = agent_id + + action_info = {} + self.pl_utils._record_trace(user_id, ActionType.DO_NOTHING.value, + action_info, current_time) + return {"success": True} + except Exception as e: + return {"success": False, "error": str(e)} + + async def interview(self, agent_id: int, interview_data): + """Interview an agent with the given prompt and record the response. + + Args: + agent_id (int): The ID of the agent being interviewed. + interview_data: Either a string (prompt only) or dict with prompt + and response. + + Returns: + dict: A dictionary with success status. + """ + if self.recsys_type == RecsysType.REDDIT: + current_time = self.sandbox_clock.time_transfer( + datetime.now(), self.start_time) + else: + current_time = self.sandbox_clock.get_time_step() + try: + user_id = agent_id + + # Handle both old format (string prompt) and new format + # (dict with prompt + response) + if isinstance(interview_data, str): + # Old format: just the prompt + prompt = interview_data + response = None + interview_id = f"{current_time}_{user_id}" + action_info = {"prompt": prompt, "interview_id": interview_id} + else: + # New format: dict with prompt and response + prompt = interview_data.get("prompt", "") + response = interview_data.get("response", "") + interview_id = f"{current_time}_{user_id}" + action_info = { + "prompt": prompt, + "response": response, + "interview_id": interview_id + } + + # Record the interview in the trace table + self.pl_utils._record_trace(user_id, ActionType.INTERVIEW.value, + action_info, current_time) + + return {"success": True, "interview_id": interview_id} + except Exception as e: + return {"success": False, "error": str(e)} + + async def report_post(self, agent_id: int, report_message: tuple): + post_id, report_reason = report_message + if self.recsys_type == RecsysType.REDDIT: + current_time = self.sandbox_clock.time_transfer( + datetime.now(), self.start_time) + else: + current_time = self.sandbox_clock.get_time_step() + try: + user_id = agent_id + post_type_result = self.pl_utils._get_post_type(post_id) + + # Check if a report record already exists + check_report_query = ( + "SELECT * FROM report WHERE user_id = ? AND post_id = ?") + self.pl_utils._execute_db_command(check_report_query, + (user_id, post_id)) + if self.db_cursor.fetchone(): + return { + "success": False, + "error": "Report record already exists." + } + + if not post_type_result: + return {"success": False, "error": "Post not found."} + + # Update the number of reports in the post table + update_reports_query = ( + "UPDATE post SET num_reports = num_reports + 1 WHERE " + "post_id = ?") + self.pl_utils._execute_db_command(update_reports_query, + (post_id, ), + commit=True) + + # Add a report in the report table + report_insert_query = ( + "INSERT INTO report (post_id, user_id, report_reason, " + "created_at) VALUES (?, ?, ?, ?)") + self.pl_utils._execute_db_command( + report_insert_query, + (post_id, user_id, report_reason, current_time), + commit=True) + + # Get the ID of the newly inserted report record + report_id = self.db_cursor.lastrowid + + # Record the action in the trace table + action_info = {"post_id": post_id, "report_id": report_id} + self.pl_utils._record_trace(user_id, ActionType.REPORT_POST.value, + action_info, current_time) + + return {"success": True, "report_id": report_id} + except Exception as e: + return {"success": False, "error": str(e)} + + async def send_to_group(self, agent_id: int, message: tuple): + group_id, content = message + if self.recsys_type == RecsysType.REDDIT: + current_time = self.sandbox_clock.time_transfer( + datetime.now(), self.start_time) + else: + current_time = self.sandbox_clock.get_time_step() + try: + user_id = agent_id + # check if user is a member of the group + check_query = ("SELECT * FROM group_members WHERE group_id = ? " + "AND agent_id = ?") + self.pl_utils._execute_db_command(check_query, (group_id, user_id)) + if not self.db_cursor.fetchone(): + return { + "success": False, + "error": "User is not a member of this group.", + } + + # Insert the message into the group_messages table + insert_query = """ + INSERT INTO group_messages + (group_id, sender_id, content, sent_at) + VALUES (?, ?, ?, ?) + """ + self.pl_utils._execute_db_command( + insert_query, (group_id, user_id, content, current_time), + commit=True) + message_id = self.db_cursor.lastrowid + + # get the group members + members_query = ("SELECT agent_id FROM group_members WHERE " + "group_id = ? AND agent_id != ?") + self.pl_utils._execute_db_command(members_query, + (group_id, user_id)) + members = [row[0] for row in self.db_cursor.fetchall()] + action_info = { + "group_id": group_id, + "message_id": message_id, + "content": content, + } + self.pl_utils._record_trace(user_id, + ActionType.SEND_TO_GROUP.value, + action_info, current_time) + + return {"success": True, "message_id": message_id, "to": members} + except Exception as e: + return {"success": False, "error": str(e)} + + async def create_group(self, agent_id: int, group_name: str): + if self.recsys_type == RecsysType.REDDIT: + current_time = self.sandbox_clock.time_transfer( + datetime.now(), self.start_time) + else: + current_time = self.sandbox_clock.get_time_step() + try: + user_id = agent_id + + # insert the group into the groups table + insert_query = """ + INSERT INTO chat_group (name, created_at) VALUES (?, ?) + """ + self.pl_utils._execute_db_command(insert_query, + (group_name, current_time), + commit=True) + group_id = self.db_cursor.lastrowid + + # insert the user as a member of the group + join_query = """ + INSERT INTO group_members (group_id, agent_id, joined_at) + VALUES (?, ?, ?) + """ + self.pl_utils._execute_db_command( + join_query, (group_id, user_id, current_time), commit=True) + + action_info = {"group_id": group_id, "group_name": group_name} + self.pl_utils._record_trace(user_id, ActionType.CREATE_GROUP.value, + action_info, current_time) + + return {"success": True, "group_id": group_id} + except Exception as e: + return {"success": False, "error": str(e)} + + async def join_group(self, agent_id: int, group_id: int): + if self.recsys_type == RecsysType.REDDIT: + current_time = self.sandbox_clock.time_transfer( + datetime.now(), self.start_time) + else: + current_time = self.sandbox_clock.get_time_step() + try: + user_id = agent_id + + # check if group exists + check_group_query = """SELECT * FROM chat_group + WHERE group_id = ?""" + self.pl_utils._execute_db_command(check_group_query, (group_id, )) + if not self.db_cursor.fetchone(): + return {"success": False, "error": "Group does not exist."} + + # check if user is already in the group + check_member_query = ( + "SELECT * FROM group_members WHERE group_id = ? " + "AND agent_id = ?") + self.pl_utils._execute_db_command(check_member_query, + (group_id, user_id)) + if self.db_cursor.fetchone(): + return { + "success": False, + "error": "User is already in the group." + } + + # join the group + join_query = """ + INSERT INTO group_members + (group_id, agent_id, joined_at) VALUES (?, ?, ?) + """ + self.pl_utils._execute_db_command( + join_query, (group_id, user_id, current_time), commit=True) + + action_info = {"group_id": group_id} + self.pl_utils._record_trace(user_id, ActionType.JOIN_GROUP.value, + action_info, current_time) + + return {"success": True} + except Exception as e: + return {"success": False, "error": str(e)} + + async def leave_group(self, agent_id: int, group_id: int): + try: + user_id = agent_id + + # check if user is a member of the group + check_query = ("SELECT * FROM group_members " + "WHERE group_id = ? AND agent_id = ?") + self.pl_utils._execute_db_command(check_query, (group_id, user_id)) + if not self.db_cursor.fetchone(): + return { + "success": False, + "error": "User is not a member of this group." + } + + # delete the member record + delete_query = ("DELETE FROM group_members " + "WHERE group_id = ? AND agent_id = ?") + self.pl_utils._execute_db_command(delete_query, + (group_id, user_id), + commit=True) + + action_info = {"group_id": group_id} + self.pl_utils._record_trace(user_id, ActionType.LEAVE_GROUP.value, + action_info) + + return {"success": True} + except Exception as e: + return {"success": False, "error": str(e)} + + async def listen_from_group(self, agent_id: int): + try: + # get all groups Dict[group_id, group_name] + query = """ SELECT * FROM chat_group """ + self.pl_utils._execute_db_command(query) + all_groups = {} + for row in self.db_cursor.fetchall(): + all_groups[row[0]] = row[1] + + # get all groups that the user is a member of + in_query = """ + SELECT group_id FROM group_members WHERE agent_id = ? + """ + self.pl_utils._execute_db_command(in_query, (agent_id, )) + joined_group_ids = [row[0] for row in self.db_cursor.fetchall()] + + # get all messages from those groups, Dict[group_id, [messages]] + messages = {} + for group_id in joined_group_ids: + select_query = """ + SELECT message_id, content, sender_id, + sent_at FROM group_messages WHERE group_id = ? + """ + self.pl_utils._execute_db_command(select_query, (group_id, )) + messages[group_id] = [{ + "message_id": row[0], + "content": row[1], + "sender_id": row[2], + "sent_at": row[3], + } for row in self.db_cursor.fetchall()] + + return { + "success": True, + "all_groups": all_groups, + "joined_groups": joined_group_ids, + "messages": messages + } + except Exception as e: + return {"success": False, "error": str(e)} diff --git a/backend/oasis/social_platform/platform_utils.py b/backend/oasis/social_platform/platform_utils.py new file mode 100644 index 00000000..333a4bc7 --- /dev/null +++ b/backend/oasis/social_platform/platform_utils.py @@ -0,0 +1,262 @@ +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +# Licensed under the Apache License, Version 2.0 (the “License”); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an “AS IS” BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +import json +from datetime import datetime + +from oasis.social_platform.typing import RecsysType + + +class PlatformUtils: + + def __init__(self, + db, + db_cursor, + start_time, + sandbox_clock, + show_score, + recsys_type, + report_threshold=1): + self.db = db + self.db_cursor = db_cursor + self.start_time = start_time + self.sandbox_clock = sandbox_clock + self.show_score = show_score + self.recsys_type = recsys_type + self.report_threshold = report_threshold + + @staticmethod + def _not_signup_error_message(agent_id): + return { + "success": + False, + "error": (f"Agent {agent_id} has not signed up and does not have " + f"a user id."), + } + + def _execute_db_command(self, command, args=(), commit=False): + self.db_cursor.execute(command, args) + if commit: + self.db.commit() + return self.db_cursor + + def _execute_many_db_command(self, command, args_list, commit=False): + self.db_cursor.executemany(command, args_list) + if commit: + self.db.commit() + return self.db_cursor + + def _check_agent_userid(self, agent_id): + try: + user_query = "SELECT user_id FROM user WHERE agent_id = ?" + results = self._execute_db_command(user_query, (agent_id, )) + # Fetch the first row of the query result + first_row = results.fetchone() + if first_row: + user_id = first_row[0] + return user_id + else: + return None + except Exception as e: + # Log or handle the error as appropriate + print(f"Error querying user_id for agent_id {agent_id}: {e}") + return None + + def _add_comments_to_posts(self, posts_results): + # Initialize the returned posts list + posts = [] + for row in posts_results: + (post_id, user_id, original_post_id, content, quote_content, + created_at, num_likes, num_dislikes, num_shares) = row + post_type_result = self._get_post_type(post_id) + if post_type_result is None: + continue + original_user_id_query = ( + "SELECT user_id FROM post WHERE post_id = ?") + if post_type_result["type"] == "repost": + self.db_cursor.execute(original_user_id_query, + (original_post_id, )) + original_user_id = self.db_cursor.fetchone()[0] + original_post_id = post_id + post_id = post_type_result["root_post_id"] + self.db_cursor.execute( + "SELECT content, quote_content, created_at, num_likes, " + "num_dislikes, num_shares, num_reports FROM post " + "WHERE post_id = ?", (post_id, )) + original_post_result = self.db_cursor.fetchone() + (content, quote_content, created_at, num_likes, num_dislikes, + num_shares, num_reports) = original_post_result + post_content = ( + f"User {user_id} reposted a post from User " + f"{original_user_id}. Repost content: {content}. ") + + elif post_type_result["type"] == "quote": + self.db_cursor.execute(original_user_id_query, + (original_post_id, )) + original_user_id = self.db_cursor.fetchone()[0] + post_content = ( + f"User {user_id} quoted a post from User " + f"{original_user_id}. Quote content: {quote_content}. " + f"Original Content: {content}") + + elif post_type_result["type"] == "common": + post_content = content + # Get num_reports for common posts + self.db_cursor.execute( + "SELECT num_reports FROM post WHERE post_id = ?", + (post_id, )) + num_reports = self.db_cursor.fetchone()[0] + + # For each post, query its corresponding comments + self.db_cursor.execute( + "SELECT comment_id, post_id, user_id, content, created_at, " + "num_likes, num_dislikes FROM comment WHERE post_id = ?", + (post_id, ), + ) + comments_results = self.db_cursor.fetchall() + + # Convert each comment's result into dictionary format + comments = [{ + "comment_id": + comment_id, + "post_id": + post_id, + "user_id": + user_id, + "content": + content, + "created_at": + created_at, + **({ + "score": num_likes - num_dislikes + } if self.show_score else { + "num_likes": num_likes, + "num_dislikes": num_dislikes + }), + } for ( + comment_id, + post_id, + user_id, + content, + created_at, + num_likes, + num_dislikes, + ) in comments_results] + + # Add warning message if the post has been reported + if num_reports >= self.report_threshold: + warning_message = ("[Warning: This post has been reported" + f" {num_reports} times]") + post_content = f"{warning_message}\n{post_content}" + + # Add post information and corresponding comments to the posts list + posts.append({ + "post_id": + post_id + if post_type_result["type"] != "repost" else original_post_id, + "user_id": + user_id, + "content": + post_content, + "created_at": + created_at, + **({ + "score": num_likes - num_dislikes + } if self.show_score else { + "num_likes": num_likes, + "num_dislikes": num_dislikes + }), + "num_shares": + num_shares, + "num_reports": + num_reports, + "comments": + comments, + }) + return posts + + def _record_trace(self, + user_id, + action_type, + action_info, + current_time=None): + r"""If, in addition to the trace, the operation function also records + time in other tables of the database, use the time of entering + the operation function for consistency. + + Pass in current_time to make, for example, the created_at in the post + table exactly the same as the time in the trace table. + + If only the trace table needs to record time, use the entry time into + _record_trace as the time for the trace record. + """ + if self.recsys_type == RecsysType.REDDIT: + current_time = self.sandbox_clock.time_transfer( + datetime.now(), self.start_time) + else: + current_time = self.sandbox_clock.get_time_step() + + trace_insert_query = ( + "INSERT INTO trace (user_id, created_at, action, info) " + "VALUES (?, ?, ?, ?)") + action_info_str = json.dumps(action_info) + self._execute_db_command( + trace_insert_query, + (user_id, current_time, action_type, action_info_str), + commit=True, + ) + + def _check_self_post_rating(self, post_id, user_id): + self_like_check_query = "SELECT user_id FROM post WHERE post_id = ?" + self._execute_db_command(self_like_check_query, (post_id, )) + result = self.db_cursor.fetchone() + if result and result[0] == user_id: + error_message = ("Users are not allowed to like/dislike their own " + "posts.") + return {"success": False, "error": error_message} + else: + return None + + def _check_self_comment_rating(self, comment_id, user_id): + self_like_check_query = ("SELECT user_id FROM comment WHERE " + "comment_id = ?") + self._execute_db_command(self_like_check_query, (comment_id, )) + result = self.db_cursor.fetchone() + if result and result[0] == user_id: + error_message = ("Users are not allowed to like/dislike their " + "own comments.") + return {"success": False, "error": error_message} + else: + return None + + def _get_post_type(self, post_id: int): + query = ( + "SELECT original_post_id, quote_content FROM post WHERE post_id " + "= ?") + self._execute_db_command(query, (post_id, )) + result = self.db_cursor.fetchone() + + if not result: + return None + + original_post_id, quote_content = result + + if original_post_id is None: + # common post without quote or repost + return {"type": "common", "root_post_id": None} + elif quote_content is None: + # post with repost + return {"type": "repost", "root_post_id": original_post_id} + else: + # post with quote + return {"type": "quote", "root_post_id": original_post_id} diff --git a/backend/oasis/social_platform/process_recsys_posts.py b/backend/oasis/social_platform/process_recsys_posts.py new file mode 100644 index 00000000..6181f94b --- /dev/null +++ b/backend/oasis/social_platform/process_recsys_posts.py @@ -0,0 +1,81 @@ +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +# Licensed under the Apache License, Version 2.0 (the “License”); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an “AS IS” BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +from typing import List + +import torch +from camel.embeddings import OpenAIEmbedding +from camel.types import EmbeddingModelType +from transformers import AutoModel, AutoTokenizer + + +# Function: Process each batch +@torch.no_grad() +def process_batch(model: AutoModel, tokenizer: AutoTokenizer, + batch_texts: List[str]): + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + inputs = tokenizer(batch_texts, + return_tensors="pt", + padding=True, + truncation=True) + inputs = {key: value.to(device) for key, value in inputs.items()} + outputs = model(**inputs) + return outputs.pooler_output + + +def generate_post_vector(model: AutoModel, tokenizer: AutoTokenizer, texts, + batch_size): + # Loop through all messages + # If the list of messages is too large, process them in batches. + all_outputs = [] + for i in range(0, len(texts), batch_size): + batch_texts = texts[i:i + batch_size] + batch_outputs = process_batch(model, tokenizer, batch_texts) + all_outputs.append(batch_outputs) + all_outputs_tensor = torch.cat(all_outputs, dim=0) # num_posts x dimension + return all_outputs_tensor.cpu() + + +def generate_post_vector_openai(texts: List[str], batch_size: int = 100): + """ + Generate embeddings using OpenAI API + + Args: + texts: List of texts to process + batch_size: Size of each batch + """ + openai_embedding = OpenAIEmbedding( + model_type=EmbeddingModelType.TEXT_EMBEDDING_3_SMALL) + + all_embeddings = [] + for i in range(0, len(texts), batch_size): + batch_texts = texts[i:i + batch_size] + cleaned_texts = [ + text.strip() if text and isinstance(text, str) else "empty" + for text in batch_texts + ] + batch_embeddings = openai_embedding.embed_list(objs=cleaned_texts) + batch_tensor = torch.tensor(batch_embeddings) + all_embeddings.append(batch_tensor) + + return torch.cat(all_embeddings, dim=0) + + +if __name__ == "__main__": + # Input list of strings (assuming there are tens of thousands of messages) + # Here, the same message is repeated 10000 times as an example + texts = ["I'm using TwHIN-BERT! #TwHIN-BERT #NLP"] * 10000 + # Define batch size + batch_size = 100 + all_outputs_tensor = generate_post_vector(texts, batch_size) + print(all_outputs_tensor.shape) diff --git a/backend/oasis/social_platform/recsys.py b/backend/oasis/social_platform/recsys.py new file mode 100644 index 00000000..9d9429cb --- /dev/null +++ b/backend/oasis/social_platform/recsys.py @@ -0,0 +1,797 @@ +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +# Licensed under the Apache License, Version 2.0 (the “License”); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an “AS IS” BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +'''Note that you need to check if it exceeds max_rec_post_len when writing +into rec_matrix''' +import heapq +import logging +import random +import time +from ast import literal_eval +from datetime import datetime +from math import log +from typing import Any, Dict, List + +import numpy as np +import torch +from sentence_transformers import SentenceTransformer +from sklearn.feature_extraction.text import TfidfVectorizer +from sklearn.metrics.pairwise import cosine_similarity + +from .process_recsys_posts import (generate_post_vector, + generate_post_vector_openai) +from .typing import ActionType, RecsysType + +rec_log = logging.getLogger(name='social.rec') +rec_log.setLevel('DEBUG') + +# Initially set to None, to be assigned once again in the recsys function +model = None +twhin_tokenizer = None +twhin_model = None + +# Create the TF-IDF model +tfidf_vectorizer = TfidfVectorizer() +# Prepare the twhin model +device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + +# All historical tweets and the most recent tweet of each user +user_previous_post_all = {} +user_previous_post = {} +user_profiles = [] +# Get the {post_id: content} dict +t_items = {} +# Get the {uid: follower_count} dict +# It's necessary to ensure that agent registration is sequential, with the +# relationship of user_id=agent_id+1; disorder in registration will cause +# issues here +u_items = {} +# Get the creation times of all tweets, assigning scores based on how recent +# they are +date_score = [] + + +def get_twhin_tokenizer(): + global twhin_tokenizer + if twhin_tokenizer is None: + from transformers import AutoTokenizer + twhin_tokenizer = AutoTokenizer.from_pretrained( + pretrained_model_name_or_path="Twitter/twhin-bert-base", + model_max_length=512) + return twhin_tokenizer + + +def get_twhin_model(device): + global twhin_model + if twhin_model is None: + from transformers import AutoModel + twhin_model = AutoModel.from_pretrained( + pretrained_model_name_or_path="Twitter/twhin-bert-base").to(device) + return twhin_model + + +def load_model(model_name): + try: + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + if model_name == 'paraphrase-MiniLM-L6-v2': + return SentenceTransformer(model_name, + device=device, + cache_folder="./models") + elif model_name == 'Twitter/twhin-bert-base': + twhin_tokenizer = get_twhin_tokenizer() + twhin_model = get_twhin_model(device) + return twhin_tokenizer, twhin_model + else: + raise ValueError(f"Unknown model name: {model_name}") + except Exception as e: + raise Exception(f"Failed to load the model: {model_name}") from e + + +def get_recsys_model(recsys_type: str = None): + if recsys_type == RecsysType.TWITTER.value: + model = load_model('paraphrase-MiniLM-L6-v2') + return model + elif recsys_type == RecsysType.TWHIN.value: + twhin_tokenizer, twhin_model = load_model("Twitter/twhin-bert-base") + models = (twhin_tokenizer, twhin_model) + return models + elif (recsys_type == RecsysType.REDDIT.value + or recsys_type == RecsysType.RANDOM.value): + return None + else: + raise ValueError(f"Unknown recsys type: {recsys_type}") + + +# Move model to GPU if available +device = 'cuda' if torch.cuda.is_available() else 'cpu' +if model is not None: + model.to(device) +else: + pass + + +# Reset global variables +def reset_globals(): + global user_previous_post_all, user_previous_post + global user_profiles, t_items, u_items + global date_score + user_previous_post_all = {} + user_previous_post = {} + user_profiles = [] + t_items = {} + u_items = {} + date_score = [] + + +def rec_sys_random(post_table: List[Dict[str, Any]], rec_matrix: List[List], + max_rec_post_len: int) -> List[List]: + """ + Randomly recommend posts to users. + + Args: + user_table (List[Dict[str, Any]]): List of users. + post_table (List[Dict[str, Any]]): List of posts. + trace_table (List[Dict[str, Any]]): List of user interactions. + rec_matrix (List[List]): Existing recommendation matrix. + max_rec_post_len (int): Maximum number of recommended posts. + + Returns: + List[List]: Updated recommendation matrix. + """ + # Get all post IDs + post_ids = [post['post_id'] for post in post_table] + new_rec_matrix = [] + if len(post_ids) <= max_rec_post_len: + # If the number of posts is less than or equal to the maximum number + # of recommendations, each user gets all post IDs + new_rec_matrix = [post_ids] * len(rec_matrix) + else: + # If the number of posts is greater than the maximum number of + # recommendations, each user randomly gets a specified number of post + # IDs + for _ in range(len(rec_matrix)): + new_rec_matrix.append(random.sample(post_ids, max_rec_post_len)) + + return new_rec_matrix + + +def calculate_hot_score(num_likes: int, num_dislikes: int, + created_at: datetime) -> int: + """ + Compute the hot score for a post. + + Args: + num_likes (int): Number of likes. + num_dislikes (int): Number of dislikes. + created_at (datetime): Creation time of the post. + + Returns: + int: Hot score of the post. + + Reference: + https://medium.com/hacking-and-gonzo/how-reddit-ranking-algorithms-work-ef111e33d0d9 + """ + s = num_likes - num_dislikes + order = log(max(abs(s), 1), 10) + sign = 1 if s > 0 else -1 if s < 0 else 0 + + # epoch_seconds + epoch = datetime(1970, 1, 1) + td = created_at - epoch + epoch_seconds_result = td.days * 86400 + td.seconds + ( + float(td.microseconds) / 1e6) + + seconds = epoch_seconds_result - 1134028003 + return round(sign * order + seconds / 45000, 7) + + +def get_recommendations( + user_index, + cosine_similarities, + items, + score, + top_n=100, +): + similarities = np.array(cosine_similarities[user_index]) + similarities = similarities * score + top_item_indices = similarities.argsort()[::-1][:top_n] + recommended_items = [(list(items.keys())[i], similarities[i]) + for i in top_item_indices] + return recommended_items + + +def rec_sys_reddit(post_table: List[Dict[str, Any]], rec_matrix: List[List], + max_rec_post_len: int) -> List[List]: + """ + Recommend posts based on Reddit-like hot score. + + Args: + post_table (List[Dict[str, Any]]): List of posts. + rec_matrix (List[List]): Existing recommendation matrix. + max_rec_post_len (int): Maximum number of recommended posts. + + Returns: + List[List]: Updated recommendation matrix. + """ + # Get all post IDs + post_ids = [post['post_id'] for post in post_table] + + if len(post_ids) <= max_rec_post_len: + # If the number of posts is less than or equal to the maximum number + # of recommendations, each user gets all post IDs + new_rec_matrix = [post_ids] * len(rec_matrix) + else: + # The time complexity of this recommendation system is + # O(post_num * log max_rec_post_len) + all_hot_score = [] + for post in post_table: + try: + created_at_dt = datetime.strptime(post['created_at'], + "%Y-%m-%d %H:%M:%S.%f") + except Exception: + created_at_dt = datetime.strptime(post['created_at'], + "%Y-%m-%d %H:%M:%S") + hot_score = calculate_hot_score(post['num_likes'], + post['num_dislikes'], + created_at_dt) + all_hot_score.append((hot_score, post['post_id'])) + # Sort + top_posts = heapq.nlargest(max_rec_post_len, + all_hot_score, + key=lambda x: x[0]) + top_post_ids = [post_id for _, post_id in top_posts] + + # If the number of posts is greater than the maximum number of + # recommendations, each user gets a specified number of post IDs + # randomly + new_rec_matrix = [top_post_ids] * len(rec_matrix) + + return new_rec_matrix + + +def rec_sys_personalized(user_table: List[Dict[str, Any]], + post_table: List[Dict[str, Any]], + trace_table: List[Dict[str, + Any]], rec_matrix: List[List], + max_rec_post_len: int) -> List[List]: + """ + Recommend posts based on personalized similarity scores. + + Args: + user_table (List[Dict[str, Any]]): List of users. + post_table (List[Dict[str, Any]]): List of posts. + trace_table (List[Dict[str, Any]]): List of user interactions. + rec_matrix (List[List]): Existing recommendation matrix. + max_rec_post_len (int): Maximum number of recommended posts. + + Returns: + List[List]: Updated recommendation matrix. + """ + global model + if model is None or isinstance(model, tuple): + model = get_recsys_model(recsys_type="twitter") + + post_ids = [post['post_id'] for post in post_table] + print( + f'Running personalized recommendation for {len(user_table)} users...') + start_time = time.time() + new_rec_matrix = [] + if len(post_ids) <= max_rec_post_len: + # If the number of posts is less than or equal to the maximum + # recommended length, each user gets all post IDs + new_rec_matrix = [post_ids] * len(rec_matrix) + else: + # If the number of posts is greater than the maximum recommended + # length, each user gets personalized post IDs + user_bios = [ + user['bio'] if 'bio' in user and user['bio'] is not None else '' + for user in user_table + ] + post_contents = [post['content'] for post in post_table] + + if model: + user_embeddings = model.encode(user_bios, + convert_to_tensor=True, + device=device) + post_embeddings = model.encode(post_contents, + convert_to_tensor=True, + device=device) + + # Compute dot product similarity + dot_product = torch.matmul(user_embeddings, post_embeddings.T) + + # Compute norm + user_norms = torch.norm(user_embeddings, dim=1) + post_norms = torch.norm(post_embeddings, dim=1) + + # Compute cosine similarity + similarities = dot_product / (user_norms[:, None] * + post_norms[None, :]) + + else: + # Generate random similarities + similarities = torch.rand(len(user_table), len(post_table)) + + # Iterate through each user to generate personalized recommendations. + for user_index, user in enumerate(user_table): + # Filter out posts made by the current user. + filtered_post_indices = [ + i for i, post in enumerate(post_table) + if post['user_id'] != user['user_id'] + ] + + user_similarities = similarities[user_index, filtered_post_indices] + + # Get the corresponding post IDs for the filtered posts. + filtered_post_ids = [ + post_table[i]['post_id'] for i in filtered_post_indices + ] + + # Determine the top posts based on the similarities, limited by + # max_rec_post_len. + _, top_indices = torch.topk(user_similarities, + k=min(max_rec_post_len, + len(filtered_post_ids))) + + top_post_ids = [filtered_post_ids[i] for i in top_indices.tolist()] + + # Append the top post IDs to the new recommendation matrix. + new_rec_matrix.append(top_post_ids) + + end_time = time.time() + print(f'Personalized recommendation time: {end_time - start_time:.6f}s') + return new_rec_matrix + + +def get_like_post_id(user_id, action, trace_table): + """ + Get the post IDs that a user has liked or unliked. + + Args: + user_id (str): ID of the user. + action (str): Type of action (like or unlike). + post_table (list): List of posts. + trace_table (list): List of user interactions. + + Returns: + list: List of post IDs. + """ + # Get post IDs from trace table for the given user and action + trace_post_ids = [ + literal_eval(trace['info'])["post_id"] for trace in trace_table + if (trace['user_id'] == user_id and trace['action'] == action) + ] + """Only take the last 5 liked posts, if not enough, pad with the most + recently liked post. Only take IDs, not content, because calculating + embeddings for all posts again is very time-consuming, especially when the + number of agents is large""" + if len(trace_post_ids) < 5 and len(trace_post_ids) > 0: + trace_post_ids += [trace_post_ids[-1]] * (5 - len(trace_post_ids)) + elif len(trace_post_ids) > 5: + trace_post_ids = trace_post_ids[-5:] + else: + trace_post_ids = [0] + + return trace_post_ids + + +# Calculate the average cosine similarity between liked posts and target posts +def calculate_like_similarity(liked_vectors, target_vectors): + # Calculate the norms of the vectors + liked_norms = np.linalg.norm(liked_vectors, axis=1) + target_norms = np.linalg.norm(target_vectors, axis=1) + # Calculate dot products + dot_products = np.dot(target_vectors, liked_vectors.T) + # Calculate cosine similarities + cosine_similarities = dot_products / np.outer(target_norms, liked_norms) + # Take the average + average_similarities = np.mean(cosine_similarities, axis=1) + + return average_similarities + + +def coarse_filtering(input_list, scale): + """ + Coarse filtering posts and return selected elements with their indices. + """ + if len(input_list) <= scale: + # Return elements and their indices as list of tuples (element, index) + sampled_indices = range(len(input_list)) + return (input_list, sampled_indices) + else: + # Get random sample of scale elements + sampled_indices = random.sample(range(len(input_list)), scale) + sampled_elements = [input_list[idx] for idx in sampled_indices] + # return [(input_list[idx], idx) for idx in sampled_indices] + return (sampled_elements, sampled_indices) + + +def rec_sys_personalized_twh( + user_table: List[Dict[str, Any]], + post_table: List[Dict[str, Any]], + latest_post_count: int, + trace_table: List[Dict[str, Any]], + rec_matrix: List[List], + max_rec_post_len: int, + current_time: int, + # source_post_indexs: List[int], + recall_only: bool = False, + enable_like_score: bool = False, + use_openai_embedding: bool = False) -> List[List]: + global twhin_model, twhin_tokenizer + if twhin_model is None or twhin_tokenizer is None: + twhin_tokenizer, twhin_model = get_recsys_model( + recsys_type="twhin-bert") + # Set some global variables to reduce time consumption + global date_score, t_items, u_items, user_previous_post + global user_previous_post_all, user_profiles + # Get the uid: follower_count dict + # Update only once, unless adding the feature to include new users midway. + if (not u_items) or len(u_items) != len(user_table): + u_items = { + user['user_id']: user["num_followers"] + for user in user_table + } + if not user_previous_post_all or len(user_previous_post_all) != len( + user_table): + # Each user must have a list of historical tweets + user_previous_post_all = { + index: [] + for index in range(len(user_table)) + } + user_previous_post = {index: "" for index in range(len(user_table))} + if not user_profiles or len(user_profiles) != len(user_table): + for user in user_table: + if user['bio'] is None: + user_profiles.append('This user does not have profile') + else: + user_profiles.append(user['bio']) + + if len(t_items) < len(post_table): + for post in post_table[-latest_post_count:]: + # Get the {post_id: content} dict, update only the latest tweets + t_items[post['post_id']] = post['content'] + # Update the user's historical tweets + user_previous_post_all[post['user_id']].append(post['content']) + user_previous_post[post['user_id']] = post['content'] + # Get the creation times of all tweets, assigning scores based on + # how recent they are, note that this algorithm can run for a + # maximum of 90 time steps + date_score.append( + np.log( + (271.8 - (current_time - int(post['created_at']))) / 100)) + + date_score_np = np.array(date_score) + + if enable_like_score: + # Calculate similarity with previously liked content, first gather + # liked post ids from the trace + like_post_ids_all = [] + for user in user_table: + user_id = user['agent_id'] + like_post_ids = get_like_post_id(user_id, + ActionType.LIKE_POST.value, + trace_table) + like_post_ids_all.append(like_post_ids) + scores = date_score_np + new_rec_matrix = [] + if len(post_table) <= max_rec_post_len: + # If the number of tweets is less than or equal to the max + # recommendation count, each user gets all post IDs + tids = [t['post_id'] for t in post_table] + new_rec_matrix = [tids] * (len(rec_matrix)) + + else: + # If the number of tweets is greater than the max recommendation + # count, each user randomly gets personalized post IDs + + # This requires going through all users to update their profiles, + # which is a time-consuming operation + for post_user_index in user_previous_post: + try: + # Directly replacing the profile with the latest tweet will + # cause the recommendation system to repeatedly push other + # reposts to users who have already shared that tweet + # user_profiles[post_user_index] = + # user_previous_post[post_user_index] + # Instead, append the description of the Recent post's content + # to the end of the user char + update_profile = ( + f" # Recent post:{user_previous_post[post_user_index]}") + if user_previous_post[post_user_index] != "": + # If there's no update for the recent post, add this part + if "# Recent post:" not in user_profiles[post_user_index]: + user_profiles[post_user_index] += update_profile + # If the profile has a recent post but it's not the user's + # latest, replace it + elif update_profile not in user_profiles[post_user_index]: + user_profiles[post_user_index] = user_profiles[ + post_user_index].split( + "# Recent post:")[0] + update_profile + except Exception: + print("update previous post failed") + + # coarse filtering 4000 posts due to the memory constraint. + filtered_posts_tuple = coarse_filtering(list(t_items.values()), 4000) + corpus = user_profiles + filtered_posts_tuple[0] + # corpus = user_profiles + list(t_items.values()) + tweet_vector_start_t = time.time() + if use_openai_embedding: + all_post_vector_list = generate_post_vector_openai(corpus, + batch_size=1000) + else: + all_post_vector_list = generate_post_vector(twhin_model, + twhin_tokenizer, + corpus, + batch_size=1000) + tweet_vector_end_t = time.time() + rec_log.info( + f"twhin model cost time: {tweet_vector_end_t-tweet_vector_start_t}" + ) + user_vector = all_post_vector_list[:len(user_profiles)] + posts_vector = all_post_vector_list[len(user_profiles):] + + if enable_like_score: + # Traverse all liked post ids, collecting liked post vectors from + # posts_vector for matrix acceleration calculation + like_posts_vectors = [] + for user_idx, like_post_ids in enumerate(like_post_ids_all): + if len(like_post_ids) != 1: + for like_post_id in like_post_ids: + try: + like_posts_vectors.append( + posts_vector[like_post_id - 1]) + except Exception: + like_posts_vectors.append(user_vector[user_idx]) + else: + like_posts_vectors += [ + user_vector[user_idx] for _ in range(5) + ] + try: + like_posts_vectors = torch.stack(like_posts_vectors).view( + len(user_table), 5, posts_vector.shape[1]) + except Exception: + import pdb # noqa: F811 + pdb.set_trace() + get_similar_start_t = time.time() + cosine_similarities = cosine_similarity(user_vector, posts_vector) + get_similar_end_t = time.time() + rec_log.info(f"get cosine_similarity time: " + f"{get_similar_end_t-get_similar_start_t}") + if enable_like_score: + for user_index, profile in enumerate(user_profiles): + user_like_posts_vector = like_posts_vectors[user_index] + like_scores = calculate_like_similarity( + user_like_posts_vector, posts_vector) + try: + scores = scores + like_scores + except Exception: + import pdb + pdb.set_trace() + + filter_posts_index = filtered_posts_tuple[1] + cosine_similarities = cosine_similarities * scores[filter_posts_index] + cosine_similarities = torch.tensor(cosine_similarities) + value, indices = torch.topk(cosine_similarities, + max_rec_post_len, + dim=1, + largest=True, + sorted=True) + filter_posts_index = torch.tensor(filter_posts_index) + indices = filter_posts_index[indices] + # cosine_similarities = cosine_similarities * scores + # cosine_similarities = torch.tensor(cosine_similarities) + # value, indices = torch.topk(cosine_similarities, + # max_rec_post_len, + # dim=1, + # largest=True, + # sorted=True) + + matrix_list = indices.cpu().numpy() + post_list = list(t_items.keys()) + for rec_ids in matrix_list: + rec_ids = [post_list[i] for i in rec_ids] + new_rec_matrix.append(rec_ids) + + return new_rec_matrix + + +def normalize_similarity_adjustments(post_scores, base_similarity, + like_similarity, dislike_similarity): + """ + Normalize the adjustments to keep them in scale with overall similarities. + + Args: + post_scores (list): List of post scores. + base_similarity (float): Base similarity score. + like_similarity (float): Similarity score for liked posts. + dislike_similarity (float): Similarity score for disliked posts. + + Returns: + float: Adjusted similarity score. + """ + if len(post_scores) == 0: + return base_similarity + + max_score = max(post_scores, key=lambda x: x[1])[1] + min_score = min(post_scores, key=lambda x: x[1])[1] + score_range = max_score - min_score + adjustment = (like_similarity - dislike_similarity) * (score_range / 2) + return base_similarity + adjustment + + +def swap_random_posts(rec_post_ids, post_ids, swap_percent=0.1): + """ + Swap a percentage of recommended posts with random posts. + + Args: + rec_post_ids (list): List of recommended post IDs. + post_ids (list): List of all post IDs. + swap_percent (float): Percentage of posts to swap. + + Returns: + list: Updated list of recommended post IDs. + """ + num_to_swap = int(len(rec_post_ids) * swap_percent) + posts_to_swap = random.sample(post_ids, num_to_swap) + indices_to_replace = random.sample(range(len(rec_post_ids)), num_to_swap) + + for idx, new_post in zip(indices_to_replace, posts_to_swap): + rec_post_ids[idx] = new_post + + return rec_post_ids + + +def get_trace_contents(user_id, action, post_table, trace_table): + """ + Get the contents of posts that a user has interacted with. + + Args: + user_id (str): ID of the user. + action (str): Type of action (like or unlike). + post_table (list): List of posts. + trace_table (list): List of user interactions. + + Returns: + list: List of post contents. + """ + # Get post IDs from trace table for the given user and action + trace_post_ids = [ + trace['post_id'] for trace in trace_table + if (trace['user_id'] == user_id and trace['action'] == action) + ] + # Fetch post contents from post table where post IDs match those in the + # trace + trace_contents = [ + post['content'] for post in post_table + if post['post_id'] in trace_post_ids + ] + return trace_contents + + +def rec_sys_personalized_with_trace( + user_table: List[Dict[str, Any]], + post_table: List[Dict[str, Any]], + trace_table: List[Dict[str, Any]], + rec_matrix: List[List], + max_rec_post_len: int, + swap_rate: float = 0.1, +) -> List[List]: + """ + This version: + 1. If the number of posts is less than or equal to the maximum + recommended length, each user gets all post IDs + + 2. Otherwise: + - For each user, get a like-trace pool and dislike-trace pool from the + trace table + - For each user, calculate the similarity between the user's bio and + the post text + - Use the trace table to adjust the similarity score + - Swap 10% of the recommended posts with the random posts + + Personalized recommendation system that uses user interaction traces. + + Args: + user_table (List[Dict[str, Any]]): List of users. + post_table (List[Dict[str, Any]]): List of posts. + trace_table (List[Dict[str, Any]]): List of user interactions. + rec_matrix (List[List]): Existing recommendation matrix. + max_rec_post_len (int): Maximum number of recommended posts. + swap_rate (float): Percentage of posts to swap for diversity. + + Returns: + List[List]: Updated recommendation matrix. + """ + + start_time = time.time() + + new_rec_matrix = [] + post_ids = [post['post_id'] for post in post_table] + if len(post_ids) <= max_rec_post_len: + new_rec_matrix = [post_ids] * (len(rec_matrix) - 1) + else: + for idx in range(1, len(rec_matrix)): + user_id = user_table[idx - 1]['user_id'] + user_bio = user_table[idx - 1]['bio'] + # filter out posts that belong to the user + available_post_contents = [(post['post_id'], post['content']) + for post in post_table + if post['user_id'] != user_id] + + # filter out like-trace and dislike-trace + like_trace_contents = get_trace_contents( + user_id, ActionType.LIKE_POST.value, post_table, trace_table) + dislike_trace_contents = get_trace_contents( + user_id, ActionType.UNLIKE_POST.value, post_table, trace_table) + # calculate similarity between user bio and post text + post_scores = [] + for post_id, post_content in available_post_contents: + if model is not None: + user_embedding = model.encode(user_bio) + post_embedding = model.encode(post_content) + base_similarity = np.dot( + user_embedding, + post_embedding) / (np.linalg.norm(user_embedding) * + np.linalg.norm(post_embedding)) + post_scores.append((post_id, base_similarity)) + else: + post_scores.append((post_id, random.random())) + + new_post_scores = [] + # adjust similarity based on like and dislike traces + for _post_id, _base_similarity in post_scores: + _post_content = post_table[post_ids.index(_post_id)]['content'] + like_similarity = sum( + np.dot(model.encode(_post_content), model.encode(like)) / + (np.linalg.norm(model.encode(_post_content)) * + np.linalg.norm(model.encode(like))) + for like in like_trace_contents) / len( + like_trace_contents) if like_trace_contents else 0 + dislike_similarity = sum( + np.dot(model.encode(_post_content), model.encode(dislike)) + / (np.linalg.norm(model.encode(_post_content)) * + np.linalg.norm(model.encode(dislike))) + for dislike in dislike_trace_contents) / len( + dislike_trace_contents + ) if dislike_trace_contents else 0 + + # Normalize and apply adjustments + adjusted_similarity = normalize_similarity_adjustments( + post_scores, _base_similarity, like_similarity, + dislike_similarity) + new_post_scores.append((_post_id, adjusted_similarity)) + + # sort posts by similarity + new_post_scores.sort(key=lambda x: x[1], reverse=True) + # extract post ids + rec_post_ids = [ + post_id for post_id, _ in new_post_scores[:max_rec_post_len] + ] + + if swap_rate > 0: + # swap the recommended posts with random posts + swap_free_ids = [ + post_id for post_id in post_ids + if post_id not in rec_post_ids and post_id not in [ + trace['post_id'] + for trace in trace_table if trace['user_id'] + ] + ] + rec_post_ids = swap_random_posts(rec_post_ids, swap_free_ids, + swap_rate) + + new_rec_matrix.append(rec_post_ids) + end_time = time.time() + print(f'Personalized recommendation time: {end_time - start_time:.6f}s') + return new_rec_matrix diff --git a/backend/oasis/social_platform/schema/chat_group.sql b/backend/oasis/social_platform/schema/chat_group.sql new file mode 100644 index 00000000..1d00a494 --- /dev/null +++ b/backend/oasis/social_platform/schema/chat_group.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS `chat_group` ( + group_id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); diff --git a/backend/oasis/social_platform/schema/comment.sql b/backend/oasis/social_platform/schema/comment.sql new file mode 100644 index 00000000..71c70d46 --- /dev/null +++ b/backend/oasis/social_platform/schema/comment.sql @@ -0,0 +1,12 @@ +-- This is the schema definition for the comment table +CREATE TABLE comment ( + comment_id INTEGER PRIMARY KEY AUTOINCREMENT, + post_id INTEGER, + user_id INTEGER, + content TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + num_likes INTEGER DEFAULT 0, + num_dislikes INTEGER DEFAULT 0, + FOREIGN KEY(post_id) REFERENCES post(post_id), + FOREIGN KEY(user_id) REFERENCES user(user_id) +); diff --git a/backend/oasis/social_platform/schema/comment_dislike.sql b/backend/oasis/social_platform/schema/comment_dislike.sql new file mode 100644 index 00000000..b118d3a4 --- /dev/null +++ b/backend/oasis/social_platform/schema/comment_dislike.sql @@ -0,0 +1,9 @@ +-- This is the schema definition for the comment_dislike table +CREATE TABLE comment_dislike ( + comment_dislike_id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + comment_id INTEGER, + created_at DATETIME, + FOREIGN KEY(user_id) REFERENCES user(user_id), + FOREIGN KEY(comment_id) REFERENCES comment(comment_id) +); diff --git a/backend/oasis/social_platform/schema/comment_like.sql b/backend/oasis/social_platform/schema/comment_like.sql new file mode 100644 index 00000000..d60c0a86 --- /dev/null +++ b/backend/oasis/social_platform/schema/comment_like.sql @@ -0,0 +1,9 @@ +-- This is the schema definition for the comment_like table +CREATE TABLE comment_like ( + comment_like_id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + comment_id INTEGER, + created_at DATETIME, + FOREIGN KEY(user_id) REFERENCES user(user_id), + FOREIGN KEY(comment_id) REFERENCES comment(comment_id) +); diff --git a/backend/oasis/social_platform/schema/dislike.sql b/backend/oasis/social_platform/schema/dislike.sql new file mode 100644 index 00000000..120df591 --- /dev/null +++ b/backend/oasis/social_platform/schema/dislike.sql @@ -0,0 +1,9 @@ +-- This is the schema definition for the dislike table +CREATE TABLE dislike ( + dislike_id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + post_id INTEGER, + created_at DATETIME, + FOREIGN KEY(user_id) REFERENCES user(user_id), + FOREIGN KEY(post_id) REFERENCES tweet(post_id) +); diff --git a/backend/oasis/social_platform/schema/follow.sql b/backend/oasis/social_platform/schema/follow.sql new file mode 100644 index 00000000..c747e82e --- /dev/null +++ b/backend/oasis/social_platform/schema/follow.sql @@ -0,0 +1,9 @@ +-- This is the schema definition for the follow table +CREATE TABLE follow ( + follow_id INTEGER PRIMARY KEY AUTOINCREMENT, + follower_id INTEGER, + followee_id INTEGER, + created_at DATETIME, + FOREIGN KEY(follower_id) REFERENCES user(user_id), + FOREIGN KEY(followee_id) REFERENCES user(user_id) +); diff --git a/backend/oasis/social_platform/schema/group_member.sql b/backend/oasis/social_platform/schema/group_member.sql new file mode 100644 index 00000000..3411cdb4 --- /dev/null +++ b/backend/oasis/social_platform/schema/group_member.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS group_members ( + group_id INTEGER NOT NULL, + agent_id INTEGER NOT NULL, + joined_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (group_id, agent_id), + FOREIGN KEY (group_id) REFERENCES chat_group(group_id) +); diff --git a/backend/oasis/social_platform/schema/group_message.sql b/backend/oasis/social_platform/schema/group_message.sql new file mode 100644 index 00000000..6b4eefdf --- /dev/null +++ b/backend/oasis/social_platform/schema/group_message.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS group_messages ( + message_id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id INTEGER NOT NULL, + sender_id INTEGER NOT NULL, + content TEXT NOT NULL, + sent_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (group_id) REFERENCES chat_group(group_id), + FOREIGN KEY (sender_id) REFERENCES user(agent_id) +); diff --git a/backend/oasis/social_platform/schema/like.sql b/backend/oasis/social_platform/schema/like.sql new file mode 100644 index 00000000..82c74076 --- /dev/null +++ b/backend/oasis/social_platform/schema/like.sql @@ -0,0 +1,9 @@ +-- This is the schema definition for the like table +CREATE TABLE like ( + like_id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + post_id INTEGER, + created_at DATETIME, + FOREIGN KEY(user_id) REFERENCES user(user_id), + FOREIGN KEY(post_id) REFERENCES tweet(post_id) +); diff --git a/backend/oasis/social_platform/schema/mute.sql b/backend/oasis/social_platform/schema/mute.sql new file mode 100644 index 00000000..cefa72c4 --- /dev/null +++ b/backend/oasis/social_platform/schema/mute.sql @@ -0,0 +1,9 @@ +-- This is the schema definition for the mute table +CREATE TABLE mute ( + mute_id INTEGER PRIMARY KEY AUTOINCREMENT, + muter_id INTEGER, + mutee_id INTEGER, + created_at DATETIME, + FOREIGN KEY(muter_id) REFERENCES user(user_id), + FOREIGN KEY(mutee_id) REFERENCES user(user_id) +); diff --git a/backend/oasis/social_platform/schema/post.sql b/backend/oasis/social_platform/schema/post.sql new file mode 100644 index 00000000..d668299b --- /dev/null +++ b/backend/oasis/social_platform/schema/post.sql @@ -0,0 +1,16 @@ +-- This is the schema definition for the post table +-- Add Images, location etc.? +CREATE TABLE post ( + post_id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + original_post_id INTEGER, -- NULL if this is an original post + content TEXT DEFAULT '', -- DEFAULT '' for initial posts + quote_content TEXT, -- NULL if this is an original post or a repost + created_at DATETIME, + num_likes INTEGER DEFAULT 0, + num_dislikes INTEGER DEFAULT 0, + num_shares INTEGER DEFAULT 0, -- num_shares = num_reposts + num_quotes + num_reports INTEGER DEFAULT 0, + FOREIGN KEY(user_id) REFERENCES user(user_id), + FOREIGN KEY(original_post_id) REFERENCES post(post_id) +); diff --git a/backend/oasis/social_platform/schema/product.sql b/backend/oasis/social_platform/schema/product.sql new file mode 100644 index 00000000..ad8060b8 --- /dev/null +++ b/backend/oasis/social_platform/schema/product.sql @@ -0,0 +1,6 @@ +-- This is the schema definition for the product table +CREATE TABLE product ( + product_id INTEGER PRIMARY KEY, + product_name TEXT, + sales INTEGER DEFAULT 0 +); diff --git a/backend/oasis/social_platform/schema/rec.sql b/backend/oasis/social_platform/schema/rec.sql new file mode 100644 index 00000000..89b6a694 --- /dev/null +++ b/backend/oasis/social_platform/schema/rec.sql @@ -0,0 +1,8 @@ +-- This is the schema definition for the rec table +CREATE TABLE rec ( + user_id INTEGER, + post_id INTEGER, + PRIMARY KEY(user_id, post_id), + FOREIGN KEY(user_id) REFERENCES user(user_id) + FOREIGN KEY(post_id) REFERENCES tweet(post_id) +); diff --git a/backend/oasis/social_platform/schema/report.sql b/backend/oasis/social_platform/schema/report.sql new file mode 100644 index 00000000..232c2e0a --- /dev/null +++ b/backend/oasis/social_platform/schema/report.sql @@ -0,0 +1,10 @@ +-- This is the schema definition for the report table +CREATE TABLE report ( + report_id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + post_id INTEGER, + report_reason TEXT, + created_at DATETIME, + FOREIGN KEY(user_id) REFERENCES user(user_id), + FOREIGN KEY(post_id) REFERENCES post(post_id) +); diff --git a/backend/oasis/social_platform/schema/trace.sql b/backend/oasis/social_platform/schema/trace.sql new file mode 100644 index 00000000..13b47afa --- /dev/null +++ b/backend/oasis/social_platform/schema/trace.sql @@ -0,0 +1,9 @@ +-- This is the schema definition for the trace table +CREATE TABLE trace ( + user_id INTEGER, + created_at DATETIME, + action TEXT, + info TEXT, + PRIMARY KEY(user_id, created_at, action, info), + FOREIGN KEY(user_id) REFERENCES user(user_id) +); diff --git a/backend/oasis/social_platform/schema/user.sql b/backend/oasis/social_platform/schema/user.sql new file mode 100644 index 00000000..a5b4d0d6 --- /dev/null +++ b/backend/oasis/social_platform/schema/user.sql @@ -0,0 +1,11 @@ +-- This is the schema definition for the user table +CREATE TABLE user ( + user_id INTEGER PRIMARY KEY AUTOINCREMENT, + agent_id INTEGER, + user_name TEXT, + name TEXT, + bio TEXT, + created_at DATETIME, + num_followings INTEGER DEFAULT 0, + num_followers INTEGER DEFAULT 0 +); diff --git a/backend/oasis/social_platform/typing.py b/backend/oasis/social_platform/typing.py new file mode 100644 index 00000000..0a5700e8 --- /dev/null +++ b/backend/oasis/social_platform/typing.py @@ -0,0 +1,90 @@ +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +# Licensed under the Apache License, Version 2.0 (the “License”); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an “AS IS” BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +from enum import Enum + + +class ActionType(Enum): + EXIT = "exit" + REFRESH = "refresh" + SEARCH_USER = "search_user" + SEARCH_POSTS = "search_posts" + CREATE_POST = "create_post" + LIKE_POST = "like_post" + UNLIKE_POST = "unlike_post" + DISLIKE_POST = "dislike_post" + UNDO_DISLIKE_POST = "undo_dislike_post" + REPORT_POST = "report_post" + FOLLOW = "follow" + UNFOLLOW = "unfollow" + MUTE = "mute" + UNMUTE = "unmute" + TREND = "trend" + SIGNUP = "sign_up" + REPOST = "repost" + QUOTE_POST = "quote_post" + UPDATE_REC_TABLE = "update_rec_table" + CREATE_COMMENT = "create_comment" + LIKE_COMMENT = "like_comment" + UNLIKE_COMMENT = "unlike_comment" + DISLIKE_COMMENT = "dislike_comment" + UNDO_DISLIKE_COMMENT = "undo_dislike_comment" + DO_NOTHING = "do_nothing" + PURCHASE_PRODUCT = "purchase_product" + INTERVIEW = "interview" + JOIN_GROUP = "join_group" + LEAVE_GROUP = "leave_group" + SEND_TO_GROUP = "send_to_group" + CREATE_GROUP = "create_group" + LISTEN_FROM_GROUP = "listen_from_group" + + @classmethod + def get_default_twitter_actions(cls): + return [ + cls.CREATE_POST, + cls.LIKE_POST, + cls.REPOST, + cls.FOLLOW, + cls.DO_NOTHING, + cls.QUOTE_POST, + ] + + @classmethod + def get_default_reddit_actions(cls): + return [ + cls.LIKE_POST, + cls.DISLIKE_POST, + cls.CREATE_POST, + cls.CREATE_COMMENT, + cls.LIKE_COMMENT, + cls.DISLIKE_COMMENT, + cls.SEARCH_POSTS, + cls.SEARCH_USER, + cls.TREND, + cls.REFRESH, + cls.DO_NOTHING, + cls.FOLLOW, + cls.MUTE, + ] + + +class RecsysType(Enum): + TWITTER = "twitter" + TWHIN = "twhin-bert" + REDDIT = "reddit" + RANDOM = "random" + + +class DefaultPlatformType(Enum): + TWITTER = "twitter" + REDDIT = "reddit" diff --git a/backend/oasis/testing/__init__.py b/backend/oasis/testing/__init__.py new file mode 100644 index 00000000..d963f8cc --- /dev/null +++ b/backend/oasis/testing/__init__.py @@ -0,0 +1,13 @@ +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +# Licensed under the Apache License, Version 2.0 (the “License”); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an “AS IS” BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== diff --git a/backend/oasis/testing/show_db.py b/backend/oasis/testing/show_db.py new file mode 100644 index 00000000..986102d9 --- /dev/null +++ b/backend/oasis/testing/show_db.py @@ -0,0 +1,64 @@ +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +# Licensed under the Apache License, Version 2.0 (the “License”); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an “AS IS” BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +import logging +import sqlite3 +from datetime import datetime + +table_log = logging.getLogger(name="table") +table_log.setLevel("DEBUG") +now = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") # Modify here +file_handler = logging.FileHandler(f"./log/table-{str(now)}.log", + encoding="utf-8") +file_handler.setLevel("DEBUG") +file_handler.setFormatter(logging.Formatter("%(message)s")) +table_log.addHandler(file_handler) +stream_handler = logging.StreamHandler() +stream_handler.setLevel("DEBUG") +stream_handler.setFormatter(logging.Formatter("%(message)s")) +table_log.addHandler(stream_handler) + + +def print_db_contents(db_file): + # Connect to the SQLite database + conn = sqlite3.connect(db_file) + cursor = conn.cursor() + + # Retrieve and print all table names + cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") + tables = cursor.fetchall() + # print("Tables:", [table[0] for table in tables]) + table_log.info("Tables:" + " ".join([str(table[0]) for table in tables])) + + for table_name in tables: + # print(f"\nTable: {table_name[0]}") + table_log.info(f"\nTable: {table_name[0]}") + # Print table structure + cursor.execute(f"PRAGMA table_info({table_name[0]})") + columns = cursor.fetchall() + # print("Columns:") + table_log.info("Columns:") + for col in columns: + # print(f" {col[1]} ({col[2]})") + table_log.info(f" {col[1]} ({col[2]})") + + # Print table contents + cursor.execute(f"SELECT * FROM {table_name[0]}") + rows = cursor.fetchall() + # print("Contents:") + table_log.info("Contents:") + for row in rows: + # print(" ", row) + table_log.info(" " + ", ".join(str(item) for item in row)) + # Close the connection + conn.close() diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 4f5361d5..c17c9259 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -3,7 +3,7 @@ name = "mirofish-backend" version = "0.1.0" description = "MiroFish - 简洁通用的群体智能引擎,预测万物" requires-python = ">=3.11" -license = { text = "AGPL-3.0" } +license = "AGPL-3.0" authors = [ { name = "MiroFish Team" } ] @@ -19,10 +19,6 @@ dependencies = [ # Zep Cloud "zep-cloud==3.13.0", - # OASIS 社交媒体模拟 - "camel-oasis==0.2.5", - "camel-ai==0.2.78", - # 文件处理 "PyMuPDF>=1.24.0", # 编码检测(支持非UTF-8编码的文本文件) @@ -35,6 +31,13 @@ dependencies = [ ] [project.optional-dependencies] +simulation = [ + "camel-ai==0.2.78", + "igraph==0.11.6", + "neo4j==5.23.0", + "pandas==2.2.2", + "sentence-transformers==3.0.0", +] dev = [ "pytest>=8.0.0", "pytest-asyncio>=0.23.0", @@ -52,4 +55,4 @@ dev = [ ] [tool.hatch.build.targets.wheel] -packages = ["app"] +packages = ["app", "oasis"] diff --git a/backend/requirements-simulation.txt b/backend/requirements-simulation.txt new file mode 100644 index 00000000..54fa47aa --- /dev/null +++ b/backend/requirements-simulation.txt @@ -0,0 +1,14 @@ +# Optional OASIS simulation runtime dependencies. +# Install after requirements.txt when you need Step 3 / Step 5 simulation execution: +# pip install -r requirements.txt -r requirements-simulation.txt +# On Python 3.13+, camel-ai currently pulls a tiktoken build that needs Rust. +# Prefer Python 3.11/3.12 for this optional path unless rustc is already installed. +# The upstream OASIS Python package is vendored under backend/oasis so the +# optional install only needs the explicit runtime dependencies listed below. + +-r requirements.txt +camel-ai==0.2.78 +igraph==0.11.6 +neo4j==5.23.0 +pandas==2.2.2 +sentence-transformers==3.0.0 diff --git a/backend/requirements.txt b/backend/requirements.txt index 4f146296..e70901eb 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -16,11 +16,6 @@ openai>=1.0.0 # ============= Zep Cloud ============= zep-cloud==3.13.0 -# ============= OASIS 社交媒体模拟 ============= -# OASIS 社交模拟框架 -camel-oasis==0.2.5 -camel-ai==0.2.78 - # ============= 文件处理 ============= PyMuPDF>=1.24.0 # 编码检测(支持非UTF-8编码的文本文件) @@ -33,3 +28,6 @@ python-dotenv>=1.0.0 # 数据验证 pydantic>=2.0.0 + +# 可选:OASIS 社交模拟运行时 +# 如需 Step 3 / Step 5 仿真运行能力,请额外安装 requirements-simulation.txt diff --git a/backend/run.py b/backend/run.py index 4e3b04fa..79960d41 100644 --- a/backend/run.py +++ b/backend/run.py @@ -20,17 +20,19 @@ from app import create_app from app.config import Config +from app.i18n import get_locale, tr def main(): """主函数""" # 验证配置 - errors = Config.validate() + locale = get_locale(os.environ.get("MIROFISH_LOCALE")) + errors = Config.validate(locale=locale) if errors: - print("配置错误:") + print(tr("startup.config_error_header", locale)) for err in errors: print(f" - {err}") - print("\n请检查 .env 文件中的配置") + print(f"\n{tr('startup.config_hint', locale)}") sys.exit(1) # 创建应用 @@ -47,4 +49,3 @@ def main(): if __name__ == '__main__': main() - diff --git a/backend/scripts/llm_env.py b/backend/scripts/llm_env.py new file mode 100644 index 00000000..3c486121 --- /dev/null +++ b/backend/scripts/llm_env.py @@ -0,0 +1,396 @@ +"""Helpers for resolving OpenAI-compatible LLM environment aliases.""" + +from __future__ import annotations + +import os + +SCRIPT_MESSAGES = { + "missing_dependency": { + "zh": "错误: 缺少依赖 {dependency}", + "en": "Error: missing dependency {dependency}", + }, + "install_simulation_deps_npm": { + "zh": "请先安装可选仿真依赖: `npm run setup:backend:simulation`", + "en": "Install the optional simulation dependencies first: `npm run setup:backend:simulation`", + }, + "install_simulation_deps_uv": { + "zh": "或在 backend 目录执行: `uv sync --extra simulation`", + "en": "Or run `uv sync --extra simulation` inside the backend directory", + }, + "env_loaded": { + "zh": "已加载环境配置: {path}", + "en": "Loaded environment configuration: {path}", + }, + "init": { + "zh": "初始化...", + "en": "Initializing...", + }, + "agent_count": { + "zh": " - Agent数量: {count}", + "en": " - Agent count: {count}", + }, + "init_model": { + "zh": "\n初始化LLM模型...", + "en": "\nInitializing LLM model...", + }, + "load_profiles": { + "zh": "加载Agent Profile...", + "en": "Loading agent profiles...", + }, + "profile_missing": { + "zh": "错误: Profile文件不存在: {path}", + "en": "Error: profile file does not exist: {path}", + }, + "config_missing": { + "zh": "错误: 配置文件不存在: {path}", + "en": "Error: config file does not exist: {path}", + }, + "interview_completed": { + "zh": " Interview完成: agent_id={agent_id}", + "en": " Interview completed: agent_id={agent_id}", + }, + "interview_platform_completed": { + "zh": " Interview完成: agent_id={agent_id}, platform={platform}", + "en": " Interview completed: agent_id={agent_id}, platform={platform}", + }, + "interview_failed": { + "zh": " Interview失败: agent_id={agent_id}, error={error}", + "en": " Interview failed: agent_id={agent_id}, error={error}", + }, + "interview_platform_failed": { + "zh": " Interview失败: agent_id={agent_id}, platform={platform}, error={error}", + "en": " Interview failed: agent_id={agent_id}, platform={platform}, error={error}", + }, + "multi_platform_interview_completed": { + "zh": " Interview完成: agent_id={agent_id}, 成功平台数={success_count}/{platform_count}", + "en": " Interview completed: agent_id={agent_id}, successful platforms={success_count}/{platform_count}", + }, + "multi_platform_interview_failed": { + "zh": " Interview失败: agent_id={agent_id}, 所有平台都失败", + "en": " Interview failed: agent_id={agent_id}, all platforms failed", + }, + "batch_interview_completed": { + "zh": " 批量Interview完成: {count} 个Agent", + "en": " Batch interview completed: {count} agents", + }, + "batch_interview_failed": { + "zh": " 批量Interview失败: {error}", + "en": " Batch interview failed: {error}", + }, + "twitter_batch_interview_failed": { + "zh": " Twitter批量Interview失败: {error}", + "en": " Twitter batch interview failed: {error}", + }, + "reddit_batch_interview_failed": { + "zh": " Reddit批量Interview失败: {error}", + "en": " Reddit batch interview failed: {error}", + }, + "unknown_error": { + "zh": "未知错误", + "en": "unknown error", + }, + "agent_lookup_warning": { + "zh": " 警告: 无法获取Agent {agent_id}: {error}", + "en": " Warning: failed to load agent {agent_id}: {error}", + }, + "no_valid_agents": { + "zh": "没有有效的Agent", + "en": "No valid agents were found", + }, + "no_successful_interviews": { + "zh": "没有成功的采访", + "en": "No interviews completed successfully", + }, + "platform_unavailable": { + "zh": "{platform}平台不可用", + "en": "{platform} platform is unavailable", + }, + "no_available_simulation_env": { + "zh": "没有可用的模拟环境", + "en": "No simulation environment is available", + }, + "platform_agent_lookup_warning": { + "zh": " 警告: 无法获取{platform} Agent {agent_id}: {error}", + "en": " Warning: failed to load {platform} agent {agent_id}: {error}", + }, + "interview_result_read_failed": { + "zh": " 读取Interview结果失败: {error}", + "en": " Failed to read interview result: {error}", + }, + "db_actions_read_failed": { + "zh": "读取数据库动作失败: {error}", + "en": "Failed to read database actions: {error}", + }, + "action_context_enrich_failed": { + "zh": "补充动作上下文失败: {error}", + "en": "Failed to enrich action context: {error}", + }, + "ipc_command_received": { + "zh": "\n收到IPC命令: {command_type}, id={command_id}", + "en": "\nReceived IPC command: {command_type}, id={command_id}", + }, + "close_command_received": { + "zh": "收到关闭环境命令", + "en": "Received close-environment command", + }, + "close_command_ack": { + "zh": "环境即将关闭", + "en": "The environment is shutting down", + }, + "unknown_command": { + "zh": "未知命令类型: {command_type}", + "en": "Unknown command type: {command_type}", + }, + "llm_config": { + "zh": "LLM配置: model={model}, base_url={base_url}...", + "en": "LLM config: model={model}, base_url={base_url}...", + }, + "llm_config_with_label": { + "zh": "{label} model={model}, base_url={base_url}...", + "en": "{label} model={model}, base_url={base_url}...", + }, + "default_base_url": { + "zh": "默认", + "en": "default", + }, + "default_llm_label": { + "zh": "[通用LLM]", + "en": "[default LLM]", + }, + "boost_llm_label": { + "zh": "[加速LLM]", + "en": "[boost LLM]", + }, + "runner_title": { + "zh": "OASIS {platform}模拟", + "en": "OASIS {platform} simulation", + }, + "cli_config_help": { + "zh": "配置文件路径 (simulation_config.json)", + "en": "Path to the configuration file (simulation_config.json)", + }, + "cli_max_rounds_help": { + "zh": "最大模拟轮数(可选,用于截断过长的模拟)", + "en": "Maximum simulation rounds (optional, truncates long simulations)", + }, + "cli_no_wait_help": { + "zh": "模拟完成后立即关闭环境,不进入等待命令模式", + "en": "Close the environment after the simulation and skip wait-for-command mode", + }, + "config_path": { + "zh": "配置文件: {path}", + "en": "Config file: {path}", + }, + "simulation_id": { + "zh": "模拟ID: {simulation_id}", + "en": "Simulation ID: {simulation_id}", + }, + "wait_mode": { + "zh": "等待命令模式: {state}", + "en": "Wait-for-command mode: {state}", + }, + "enabled": { + "zh": "启用", + "en": "enabled", + }, + "disabled": { + "zh": "禁用", + "en": "disabled", + }, + "rounds_truncated": { + "zh": "\n轮数已截断: {original} -> {current} (max_rounds={max_rounds})", + "en": "\nRounds truncated: {original} -> {current} (max_rounds={max_rounds})", + }, + "simulation_params": { + "zh": "\n模拟参数:", + "en": "\nSimulation parameters:", + }, + "total_hours": { + "zh": " - 总模拟时长: {hours}小时", + "en": " - Total duration: {hours} hours", + }, + "minutes_per_round": { + "zh": " - 每轮时间: {minutes}分钟", + "en": " - Minutes per round: {minutes}", + }, + "total_rounds": { + "zh": " - 总轮数: {rounds}", + "en": " - Total rounds: {rounds}", + }, + "max_rounds_limit": { + "zh": " - 最大轮数限制: {max_rounds}", + "en": " - Max-round limit: {max_rounds}", + }, + "effective_rounds": { + "zh": " - 实际执行轮数: {rounds} (已截断)", + "en": " - Effective rounds: {rounds} (truncated)", + }, + "old_db_removed": { + "zh": "已删除旧数据库: {path}", + "en": "Removed previous database: {path}", + }, + "creating_oasis_env": { + "zh": "创建OASIS环境...", + "en": "Creating OASIS environment...", + }, + "env_initialized": { + "zh": "环境初始化完成\n", + "en": "Environment initialization complete\n", + }, + "initial_events_start": { + "zh": "执行初始事件 ({count}条初始帖子)...", + "en": "Applying initial events ({count} initial posts)...", + }, + "initial_post_warning": { + "zh": " 警告: 无法为Agent {agent_id}创建初始帖子: {error}", + "en": " Warning: failed to create an initial post for agent {agent_id}: {error}", + }, + "initial_posts_published": { + "zh": " 已发布 {count} 条初始帖子", + "en": " Published {count} initial posts", + }, + "simulation_loop_start": { + "zh": "\n开始模拟循环...", + "en": "\nStarting simulation loop...", + }, + "simulation_loop_complete": { + "zh": "\n模拟循环完成!", + "en": "\nSimulation loop complete!", + }, + "round_progress": { + "zh": " [第{day}天, {hour:02d}:00] 第 {round}/{total_rounds} 轮 ({progress:.1f}%) - 活跃 Agent {agent_count} 个 - 已耗时: {elapsed:.1f}秒", + "en": " [Day {day}, {hour:02d}:00] Round {round}/{total_rounds} ({progress:.1f}%) - {agent_count} agents active - elapsed: {elapsed:.1f}s", + }, + "total_elapsed": { + "zh": " - 总耗时: {seconds:.1f}秒", + "en": " - Total elapsed: {seconds:.1f}s", + }, + "database_path": { + "zh": " - 数据库: {path}", + "en": " - Database: {path}", + }, + "wait_mode_banner": { + "zh": "进入等待命令模式 - 环境保持运行", + "en": "Entering wait-for-command mode: environment stays online", + }, + "supported_commands": { + "zh": "支持的命令: interview, batch_interview, close_env", + "en": "Supported commands: interview, batch_interview, close_env", + }, + "interrupt_received": { + "zh": "\n收到中断信号", + "en": "\nReceived interrupt signal", + }, + "task_cancelled": { + "zh": "\n任务被取消", + "en": "\nTask cancelled", + }, + "command_processing_failed": { + "zh": "\n命令处理出错: {error}", + "en": "\nCommand processing failed: {error}", + }, + "closing_env": { + "zh": "\n关闭环境...", + "en": "\nClosing environment...", + }, + "env_closed": { + "zh": "环境已关闭", + "en": "Environment closed", + }, + "signal_received": { + "zh": "\n收到 {signal_name} 信号,正在退出...", + "en": "\nReceived {signal_name}; shutting down...", + }, + "force_exit": { + "zh": "强制退出...", + "en": "Force exiting...", + }, + "program_interrupted": { + "zh": "\n程序被中断", + "en": "\nProgram interrupted", + }, + "process_exited": { + "zh": "模拟进程已退出", + "en": "Simulation process exited", + }, + "shutdown_round_stop": { + "zh": "收到退出信号,在第 {round} 轮停止模拟", + "en": "Received shutdown signal; stopping simulation at round {round}", + }, + "simulation_loop_summary": { + "zh": "模拟循环完成! 耗时: {elapsed:.1f}秒, 总动作: {total_actions}", + "en": "Simulation loop complete! Elapsed: {elapsed:.1f}s, total actions: {total_actions}", + }, +} + + +def _first_env(*names: str) -> str: + for name in names: + value = os.environ.get(name) + if value not in (None, ""): + return value + return "" + + +def resolve_standard_model_name() -> str: + """Resolve the standard model-name aliases used by OpenAI-compatible setups.""" + return _first_env("LLM_MODEL_NAME", "OPENAI_MODEL") + + +def resolve_standard_llm_env() -> tuple[str, str, str]: + """Resolve the standard LLM configuration aliases used by standalone runners.""" + return ( + _first_env("LLM_API_KEY", "OPENAI_API_KEY"), + _first_env("LLM_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE_URL"), + resolve_standard_model_name(), + ) + + +def _set_or_clear_env(name: str, value: str) -> None: + if value: + os.environ[name] = value + return + os.environ.pop(name, None) + + +def apply_openai_compat_env(api_key: str, base_url: str, model_name: str = "") -> None: + """Populate a deterministic OpenAI-compatible environment snapshot.""" + _set_or_clear_env("OPENAI_API_KEY", api_key) + _set_or_clear_env("OPENAI_BASE_URL", base_url) + _set_or_clear_env("OPENAI_API_BASE_URL", base_url) + _set_or_clear_env("OPENAI_MODEL", model_name) + + +def missing_api_key_message(locale: str = "zh") -> str: + """Return a consistent missing-key message for OpenAI-compatible env aliases.""" + if locale == "en": + return ( + "Missing API key configuration. Set LLM_API_KEY or OPENAI_API_KEY " + "in the project root .env file." + ) + return "缺少 API Key 配置,请在项目根目录 .env 文件中设置 LLM_API_KEY 或 OPENAI_API_KEY" + + +def script_message(key: str, locale: str = "zh", **params) -> str: + """Return deterministic localized script/runtime strings.""" + translations = SCRIPT_MESSAGES.get(key) + if not translations: + raise KeyError(f"Unknown script message key: {key}") + template = translations["en"] if locale == "en" else translations["zh"] + return template.format(**params) + + +def load_dotenv_if_available(path: str) -> bool: + """Best-effort .env loading for script entrypoints. + + The simulation helper scripts should still be able to print ``--help`` and + basic argument validation output when optional runtime dependencies have not + been installed yet. + """ + try: + from dotenv import load_dotenv + except ImportError: + return False + + load_dotenv(path) + return True diff --git a/backend/scripts/print_config_status.py b/backend/scripts/print_config_status.py new file mode 100644 index 00000000..814622e5 --- /dev/null +++ b/backend/scripts/print_config_status.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Print the backend config validation/summary without starting the server.""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import sys +import types +from pathlib import Path + +BACKEND_DIR = Path(__file__).resolve().parents[1] +APP_DIR = BACKEND_DIR / "app" +SCRIPT_APP_PACKAGE = "_mirofish_script_app" + + +def load_config_class(): + """Load app.config without executing app/__init__.py and its Flask imports.""" + package = sys.modules.get(SCRIPT_APP_PACKAGE) + if package is None: + package = types.ModuleType(SCRIPT_APP_PACKAGE) + package.__path__ = [str(APP_DIR)] + sys.modules[SCRIPT_APP_PACKAGE] = package + + config_module_name = f"{SCRIPT_APP_PACKAGE}.config" + config_module = sys.modules.get(config_module_name) + if config_module is None: + spec = importlib.util.spec_from_file_location(config_module_name, APP_DIR / "config.py") + if spec is None or spec.loader is None: + raise ImportError(f"Unable to load config module from {APP_DIR / 'config.py'}") + config_module = importlib.util.module_from_spec(spec) + sys.modules[config_module_name] = config_module + spec.loader.exec_module(config_module) + + return config_module.Config + + +Config = load_config_class() + + +def build_payload(locale: str) -> dict[str, object]: + validation = Config.validate_comprehensive(locale=locale) + return { + "success": validation.is_valid, + "data": { + "validation": validation.to_dict(), + "summary": Config.get_config_summary(), + }, + } + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Print the backend config-status payload as JSON.", + ) + parser.add_argument( + "--locale", + choices=("zh", "en"), + default="zh", + help="Validation message locale.", + ) + parser.add_argument( + "--compact", + action="store_true", + help="Print compact single-line JSON.", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + payload = build_payload(args.locale) + json.dump( + payload, + sys.stdout, + ensure_ascii=False, + indent=None if args.compact else 2, + sort_keys=True, + ) + sys.stdout.write("\n") + return 0 if payload["success"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/scripts/run_parallel_simulation.py b/backend/scripts/run_parallel_simulation.py index 2a627ffd..b473d3d3 100644 --- a/backend/scripts/run_parallel_simulation.py +++ b/backend/scripts/run_parallel_simulation.py @@ -76,6 +76,15 @@ def _utf8_open(file, mode='r', buffering=-1, encoding=None, errors=None, from datetime import datetime from typing import Dict, Any, List, Optional, Tuple +from llm_env import ( + apply_openai_compat_env, + load_dotenv_if_available, + missing_api_key_message, + resolve_standard_llm_env, + resolve_standard_model_name, + script_message, +) + # 全局变量:用于信号处理 _shutdown_event = None @@ -89,18 +98,26 @@ def _utf8_open(file, mode='r', buffering=-1, encoding=None, errors=None, sys.path.insert(0, _scripts_dir) sys.path.insert(0, _backend_dir) -# 加载项目根目录的 .env 文件(包含 LLM_API_KEY 等配置) -from dotenv import load_dotenv -_env_file = os.path.join(_project_root, '.env') -if os.path.exists(_env_file): - load_dotenv(_env_file) - print(f"已加载环境配置: {_env_file}") -else: - # 尝试加载 backend/.env - _backend_env = os.path.join(_backend_dir, '.env') - if os.path.exists(_backend_env): - load_dotenv(_backend_env) - print(f"已加载环境配置: {_backend_env}") +SCRIPT_LOCALE = "en" if os.environ.get("MIROFISH_LOCALE", "").lower().startswith("en") else "zh" + + +def _load_env_file() -> None: + env_file = os.path.join(_project_root, ".env") + if os.path.exists(env_file): + if load_dotenv_if_available(env_file): + print(script_message("env_loaded", SCRIPT_LOCALE, path=env_file)) + return + + backend_env = os.path.join(_backend_dir, ".env") + if os.path.exists(backend_env) and load_dotenv_if_available(backend_env): + print(script_message("env_loaded", SCRIPT_LOCALE, path=backend_env)) + + +_load_env_file() + + +def _t(zh: str, en: str) -> str: + return en if SCRIPT_LOCALE == "en" else zh class MaxTokensWarningFilter(logging.Filter): @@ -157,49 +174,74 @@ def init_logging_for_simulation(simulation_dir: str): from action_logger import SimulationLogManager, PlatformActionLogger -try: - from camel.models import ModelFactory - from camel.types import ModelPlatformType - import oasis - from oasis import ( - ActionType, - LLMAction, - ManualAction, - generate_twitter_agent_graph, - generate_reddit_agent_graph - ) -except ImportError as e: - print(f"错误: 缺少依赖 {e}") - print("请先安装: pip install oasis-ai camel-ai") - sys.exit(1) - - -# Twitter可用动作(不包含INTERVIEW,INTERVIEW只能通过ManualAction手动触发) -TWITTER_ACTIONS = [ - ActionType.CREATE_POST, - ActionType.LIKE_POST, - ActionType.REPOST, - ActionType.FOLLOW, - ActionType.DO_NOTHING, - ActionType.QUOTE_POST, -] - -# Reddit可用动作(不包含INTERVIEW,INTERVIEW只能通过ManualAction手动触发) -REDDIT_ACTIONS = [ - ActionType.LIKE_POST, - ActionType.DISLIKE_POST, - ActionType.CREATE_POST, - ActionType.CREATE_COMMENT, - ActionType.LIKE_COMMENT, - ActionType.DISLIKE_COMMENT, - ActionType.SEARCH_POSTS, - ActionType.SEARCH_USER, - ActionType.TREND, - ActionType.REFRESH, - ActionType.DO_NOTHING, - ActionType.FOLLOW, - ActionType.MUTE, -] +ModelFactory = None +ModelPlatformType = None +ActionType = None +LLMAction = None +ManualAction = None +generate_twitter_agent_graph = None +generate_reddit_agent_graph = None +oasis = None +TWITTER_ACTIONS = None +REDDIT_ACTIONS = None + + +def _load_simulation_dependencies() -> None: + global ModelFactory, ModelPlatformType, ActionType, LLMAction, ManualAction + global generate_twitter_agent_graph, generate_reddit_agent_graph, oasis + global TWITTER_ACTIONS, REDDIT_ACTIONS + + if ModelFactory is not None: + return + + try: + from camel.models import ModelFactory as _ModelFactory + from camel.types import ModelPlatformType as _ModelPlatformType + import oasis as _oasis + from oasis import ( + ActionType as _ActionType, + LLMAction as _LLMAction, + ManualAction as _ManualAction, + generate_twitter_agent_graph as _generate_twitter_agent_graph, + generate_reddit_agent_graph as _generate_reddit_agent_graph, + ) + except ImportError as e: + print(script_message("missing_dependency", SCRIPT_LOCALE, dependency=e)) + print(script_message("install_simulation_deps_npm", SCRIPT_LOCALE)) + print(script_message("install_simulation_deps_uv", SCRIPT_LOCALE)) + sys.exit(1) + + ModelFactory = _ModelFactory + ModelPlatformType = _ModelPlatformType + ActionType = _ActionType + LLMAction = _LLMAction + ManualAction = _ManualAction + generate_twitter_agent_graph = _generate_twitter_agent_graph + generate_reddit_agent_graph = _generate_reddit_agent_graph + oasis = _oasis + TWITTER_ACTIONS = [ + ActionType.CREATE_POST, + ActionType.LIKE_POST, + ActionType.REPOST, + ActionType.FOLLOW, + ActionType.DO_NOTHING, + ActionType.QUOTE_POST, + ] + REDDIT_ACTIONS = [ + ActionType.LIKE_POST, + ActionType.DISLIKE_POST, + ActionType.CREATE_POST, + ActionType.CREATE_COMMENT, + ActionType.LIKE_COMMENT, + ActionType.DISLIKE_COMMENT, + ActionType.SEARCH_POSTS, + ActionType.SEARCH_USER, + ActionType.TREND, + ActionType.REFRESH, + ActionType.DO_NOTHING, + ActionType.FOLLOW, + ActionType.MUTE, + ] # IPC相关常量 @@ -324,7 +366,14 @@ async def _interview_single_platform(self, agent_id: int, prompt: str, platform: env, agent_graph, actual_platform = self._get_env_and_graph(platform) if not env or not agent_graph: - return {"platform": platform, "error": f"{platform}平台不可用"} + return { + "platform": platform, + "error": script_message( + "platform_unavailable", + SCRIPT_LOCALE, + platform=platform, + ), + } try: agent = agent_graph.get_agent(agent_id) @@ -364,16 +413,35 @@ async def handle_interview(self, command_id: str, agent_id: int, prompt: str, pl if "error" in result: self.send_response(command_id, "failed", error=result["error"]) - print(f" Interview失败: agent_id={agent_id}, platform={platform}, error={result['error']}") + print( + script_message( + "interview_platform_failed", + SCRIPT_LOCALE, + agent_id=agent_id, + platform=platform, + error=result["error"], + ) + ) return False else: self.send_response(command_id, "completed", result=result) - print(f" Interview完成: agent_id={agent_id}, platform={platform}") + print( + script_message( + "interview_platform_completed", + SCRIPT_LOCALE, + agent_id=agent_id, + platform=platform, + ) + ) return True # 未指定平台:同时采访两个平台 if not self.twitter_env and not self.reddit_env: - self.send_response(command_id, "failed", error="没有可用的模拟环境") + self.send_response( + command_id, + "failed", + error=script_message("no_available_simulation_env", SCRIPT_LOCALE), + ) return False results = { @@ -405,12 +473,23 @@ async def handle_interview(self, command_id: str, agent_id: int, prompt: str, pl if success_count > 0: self.send_response(command_id, "completed", result=results) - print(f" Interview完成: agent_id={agent_id}, 成功平台数={success_count}/{len(platforms_to_interview)}") + print( + script_message( + "multi_platform_interview_completed", + SCRIPT_LOCALE, + agent_id=agent_id, + success_count=success_count, + platform_count=len(platforms_to_interview), + ) + ) return True else: - errors = [f"{p}: {r.get('error', '未知错误')}" for p, r in results["platforms"].items()] + errors = [ + f"{p}: {r.get('error', script_message('unknown_error', SCRIPT_LOCALE))}" + for p, r in results["platforms"].items() + ] self.send_response(command_id, "failed", error="; ".join(errors)) - print(f" Interview失败: agent_id={agent_id}, 所有平台都失败") + print(script_message("multi_platform_interview_failed", SCRIPT_LOCALE, agent_id=agent_id)) return False async def handle_batch_interview(self, command_id: str, interviews: List[Dict], platform: str = None) -> bool: @@ -463,7 +542,15 @@ async def handle_batch_interview(self, command_id: str, interviews: List[Dict], action_args={"prompt": prompt} ) except Exception as e: - print(f" 警告: 无法获取Twitter Agent {agent_id}: {e}") + print( + script_message( + "platform_agent_lookup_warning", + SCRIPT_LOCALE, + platform="Twitter", + agent_id=agent_id, + error=e, + ) + ) if twitter_actions: await self.twitter_env.step(twitter_actions) @@ -474,7 +561,7 @@ async def handle_batch_interview(self, command_id: str, interviews: List[Dict], result["platform"] = "twitter" results[f"twitter_{agent_id}"] = result except Exception as e: - print(f" Twitter批量Interview失败: {e}") + print(script_message("twitter_batch_interview_failed", SCRIPT_LOCALE, error=e)) # 处理Reddit平台的采访 if reddit_interviews and self.reddit_env: @@ -490,7 +577,15 @@ async def handle_batch_interview(self, command_id: str, interviews: List[Dict], action_args={"prompt": prompt} ) except Exception as e: - print(f" 警告: 无法获取Reddit Agent {agent_id}: {e}") + print( + script_message( + "platform_agent_lookup_warning", + SCRIPT_LOCALE, + platform="Reddit", + agent_id=agent_id, + error=e, + ) + ) if reddit_actions: await self.reddit_env.step(reddit_actions) @@ -501,17 +596,21 @@ async def handle_batch_interview(self, command_id: str, interviews: List[Dict], result["platform"] = "reddit" results[f"reddit_{agent_id}"] = result except Exception as e: - print(f" Reddit批量Interview失败: {e}") + print(script_message("reddit_batch_interview_failed", SCRIPT_LOCALE, error=e)) if results: self.send_response(command_id, "completed", result={ "interviews_count": len(results), "results": results }) - print(f" 批量Interview完成: {len(results)} 个Agent") + print(script_message("batch_interview_completed", SCRIPT_LOCALE, count=len(results))) return True else: - self.send_response(command_id, "failed", error="没有成功的采访") + self.send_response( + command_id, + "failed", + error=script_message("no_successful_interviews", SCRIPT_LOCALE), + ) return False def _get_interview_result(self, agent_id: int, platform: str) -> Dict[str, Any]: @@ -553,7 +652,7 @@ def _get_interview_result(self, agent_id: int, platform: str) -> Dict[str, Any]: conn.close() except Exception as e: - print(f" 读取Interview结果失败: {e}") + print(script_message("interview_result_read_failed", SCRIPT_LOCALE, error=e)) return result @@ -572,7 +671,14 @@ async def process_commands(self) -> bool: command_type = command.get("command_type") args = command.get("args", {}) - print(f"\n收到IPC命令: {command_type}, id={command_id}") + print( + script_message( + "ipc_command_received", + SCRIPT_LOCALE, + command_type=command_type, + command_id=command_id, + ) + ) if command_type == CommandType.INTERVIEW: await self.handle_interview( @@ -592,12 +698,24 @@ async def process_commands(self) -> bool: return True elif command_type == CommandType.CLOSE_ENV: - print("收到关闭环境命令") - self.send_response(command_id, "completed", result={"message": "环境即将关闭"}) + print(script_message("close_command_received", SCRIPT_LOCALE)) + self.send_response( + command_id, + "completed", + result={"message": script_message("close_command_ack", SCRIPT_LOCALE)}, + ) return False - + else: - self.send_response(command_id, "failed", error=f"未知命令类型: {command_type}") + self.send_response( + command_id, + "failed", + error=script_message( + "unknown_command", + SCRIPT_LOCALE, + command_type=command_type, + ), + ) return True @@ -741,7 +859,7 @@ def fetch_new_actions_from_db( conn.close() except Exception as e: - print(f"读取数据库动作失败: {e}") + print(script_message("db_actions_read_failed", SCRIPT_LOCALE, error=e)) return actions, new_last_rowid @@ -851,7 +969,7 @@ def _enrich_action_context( except Exception as e: # 补充上下文失败不影响主流程 - print(f"补充动作上下文失败: {e}") + print(script_message("action_context_enrich_failed", SCRIPT_LOCALE, error=e)) def _get_post_info( @@ -986,7 +1104,7 @@ def create_model(config: Dict[str, Any], use_boost: bool = False): 创建LLM模型 支持双 LLM 配置,用于并行模拟时提速: - - 通用配置:LLM_API_KEY, LLM_BASE_URL, LLM_MODEL_NAME + - 通用配置:LLM_API_KEY / OPENAI_API_KEY, LLM_BASE_URL / OPENAI_BASE_URL / OPENAI_API_BASE_URL, LLM_MODEL_NAME / OPENAI_MODEL - 加速配置(可选):LLM_BOOST_API_KEY, LLM_BOOST_BASE_URL, LLM_BOOST_MODEL_NAME 如果配置了加速 LLM,并行模拟时可以让不同平台使用不同的 API 服务商,提高并发能力。 @@ -995,6 +1113,7 @@ def create_model(config: Dict[str, Any], use_boost: bool = False): config: 模拟配置字典 use_boost: 是否使用加速 LLM 配置(如果可用) """ + _load_simulation_dependencies() # 检查是否有加速配置 boost_api_key = os.environ.get("LLM_BOOST_API_KEY", "") boost_base_url = os.environ.get("LLM_BOOST_BASE_URL", "") @@ -1006,30 +1125,32 @@ def create_model(config: Dict[str, Any], use_boost: bool = False): # 使用加速配置 llm_api_key = boost_api_key llm_base_url = boost_base_url - llm_model = boost_model or os.environ.get("LLM_MODEL_NAME", "") - config_label = "[加速LLM]" + llm_model = boost_model or resolve_standard_model_name() + config_label = script_message("boost_llm_label", SCRIPT_LOCALE) else: # 使用通用配置 - llm_api_key = os.environ.get("LLM_API_KEY", "") - llm_base_url = os.environ.get("LLM_BASE_URL", "") - llm_model = os.environ.get("LLM_MODEL_NAME", "") - config_label = "[通用LLM]" + llm_api_key, llm_base_url, llm_model = resolve_standard_llm_env() + config_label = script_message("default_llm_label", SCRIPT_LOCALE) # 如果 .env 中没有模型名,则使用 config 作为备用 if not llm_model: llm_model = config.get("llm_model", "gpt-4o-mini") # 设置 camel-ai 所需的环境变量 - if llm_api_key: - os.environ["OPENAI_API_KEY"] = llm_api_key - + apply_openai_compat_env(llm_api_key, llm_base_url, llm_model) + if not os.environ.get("OPENAI_API_KEY"): - raise ValueError("缺少 API Key 配置,请在项目根目录 .env 文件中设置 LLM_API_KEY") - - if llm_base_url: - os.environ["OPENAI_API_BASE_URL"] = llm_base_url - - print(f"{config_label} model={llm_model}, base_url={llm_base_url[:40] if llm_base_url else '默认'}...") + raise ValueError(missing_api_key_message(SCRIPT_LOCALE)) + + print( + script_message( + "llm_config_with_label", + SCRIPT_LOCALE, + label=config_label, + model=llm_model, + base_url=llm_base_url[:40] if llm_base_url else script_message("default_base_url", SCRIPT_LOCALE), + ) + ) return ModelFactory.create( model_platform=ModelPlatformType.OPENAI, @@ -1117,6 +1238,7 @@ async def run_twitter_simulation( Returns: PlatformSimulation: 包含env和agent_graph的结果对象 """ + _load_simulation_dependencies() result = PlatformSimulation() def log_info(msg): @@ -1124,7 +1246,7 @@ def log_info(msg): main_logger.info(f"[Twitter] {msg}") print(f"[Twitter] {msg}") - log_info("初始化...") + log_info(script_message("init", SCRIPT_LOCALE)) # Twitter 使用通用 LLM 配置 model = create_model(config, use_boost=False) @@ -1132,7 +1254,7 @@ def log_info(msg): # OASIS Twitter使用CSV格式 profile_path = os.path.join(simulation_dir, "twitter_profiles.csv") if not os.path.exists(profile_path): - log_info(f"错误: Profile文件不存在: {profile_path}") + log_info(script_message("profile_missing", SCRIPT_LOCALE, path=profile_path)) return result result.agent_graph = await generate_twitter_agent_graph( @@ -1160,7 +1282,7 @@ def log_info(msg): ) await result.env.reset() - log_info("环境已启动") + log_info(_t("环境已启动", "Environment started")) if action_logger: action_logger.log_simulation_start(config) @@ -1204,7 +1326,13 @@ def log_info(msg): if initial_actions: await result.env.step(initial_actions) - log_info(f"已发布 {len(initial_actions)} 条初始帖子") + log_info( + script_message( + "initial_posts_published", + SCRIPT_LOCALE, + count=len(initial_actions), + ) + ) # 记录 round 0 结束 if action_logger: @@ -1221,7 +1349,15 @@ def log_info(msg): original_rounds = total_rounds total_rounds = min(total_rounds, max_rounds) if total_rounds < original_rounds: - log_info(f"轮数已截断: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})") + log_info( + script_message( + "rounds_truncated", + SCRIPT_LOCALE, + original=original_rounds, + current=total_rounds, + max_rounds=max_rounds, + ).strip() + ) start_time = datetime.now() @@ -1229,7 +1365,13 @@ def log_info(msg): # 检查是否收到退出信号 if _shutdown_event and _shutdown_event.is_set(): if main_logger: - main_logger.info(f"收到退出信号,在第 {round_num + 1} 轮停止模拟") + main_logger.info( + script_message( + "shutdown_round_stop", + SCRIPT_LOCALE, + round=round_num + 1, + ) + ) break simulated_minutes = round_num * minutes_per_round @@ -1285,7 +1427,14 @@ def log_info(msg): result.total_actions = total_actions elapsed = (datetime.now() - start_time).total_seconds() - log_info(f"模拟循环完成! 耗时: {elapsed:.1f}秒, 总动作: {total_actions}") + log_info( + script_message( + "simulation_loop_summary", + SCRIPT_LOCALE, + elapsed=elapsed, + total_actions=total_actions, + ) + ) return result @@ -1309,6 +1458,7 @@ async def run_reddit_simulation( Returns: PlatformSimulation: 包含env和agent_graph的结果对象 """ + _load_simulation_dependencies() result = PlatformSimulation() def log_info(msg): @@ -1316,14 +1466,14 @@ def log_info(msg): main_logger.info(f"[Reddit] {msg}") print(f"[Reddit] {msg}") - log_info("初始化...") + log_info(script_message("init", SCRIPT_LOCALE)) # Reddit 使用加速 LLM 配置(如果有的话,否则回退到通用配置) model = create_model(config, use_boost=True) profile_path = os.path.join(simulation_dir, "reddit_profiles.json") if not os.path.exists(profile_path): - log_info(f"错误: Profile文件不存在: {profile_path}") + log_info(script_message("profile_missing", SCRIPT_LOCALE, path=profile_path)) return result result.agent_graph = await generate_reddit_agent_graph( @@ -1351,7 +1501,7 @@ def log_info(msg): ) await result.env.reset() - log_info("环境已启动") + log_info(_t("环境已启动", "Environment started")) if action_logger: action_logger.log_simulation_start(config) @@ -1403,7 +1553,13 @@ def log_info(msg): if initial_actions: await result.env.step(initial_actions) - log_info(f"已发布 {len(initial_actions)} 条初始帖子") + log_info( + script_message( + "initial_posts_published", + SCRIPT_LOCALE, + count=len(initial_actions), + ) + ) # 记录 round 0 结束 if action_logger: @@ -1420,7 +1576,15 @@ def log_info(msg): original_rounds = total_rounds total_rounds = min(total_rounds, max_rounds) if total_rounds < original_rounds: - log_info(f"轮数已截断: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})") + log_info( + script_message( + "rounds_truncated", + SCRIPT_LOCALE, + original=original_rounds, + current=total_rounds, + max_rounds=max_rounds, + ).strip() + ) start_time = datetime.now() @@ -1428,7 +1592,13 @@ def log_info(msg): # 检查是否收到退出信号 if _shutdown_event and _shutdown_event.is_set(): if main_logger: - main_logger.info(f"收到退出信号,在第 {round_num + 1} 轮停止模拟") + main_logger.info( + script_message( + "shutdown_round_stop", + SCRIPT_LOCALE, + round=round_num + 1, + ) + ) break simulated_minutes = round_num * minutes_per_round @@ -1484,40 +1654,47 @@ def log_info(msg): result.total_actions = total_actions elapsed = (datetime.now() - start_time).total_seconds() - log_info(f"模拟循环完成! 耗时: {elapsed:.1f}秒, 总动作: {total_actions}") + log_info( + script_message( + "simulation_loop_summary", + SCRIPT_LOCALE, + elapsed=elapsed, + total_actions=total_actions, + ) + ) return result async def main(): - parser = argparse.ArgumentParser(description='OASIS双平台并行模拟') + parser = argparse.ArgumentParser(description=_t("OASIS双平台并行模拟", "OASIS dual-platform parallel simulation")) parser.add_argument( '--config', type=str, required=True, - help='配置文件路径 (simulation_config.json)' + help=_t('配置文件路径 (simulation_config.json)', 'Path to the config file (simulation_config.json)') ) parser.add_argument( '--twitter-only', action='store_true', - help='只运行Twitter模拟' + help=_t('只运行Twitter模拟', 'Run only the Twitter simulation') ) parser.add_argument( '--reddit-only', action='store_true', - help='只运行Reddit模拟' + help=_t('只运行Reddit模拟', 'Run only the Reddit simulation') ) parser.add_argument( '--max-rounds', type=int, default=None, - help='最大模拟轮数(可选,用于截断过长的模拟)' + help=_t('最大模拟轮数(可选,用于截断过长的模拟)', 'Maximum simulation rounds (optional, truncates long simulations)') ) parser.add_argument( '--no-wait', action='store_true', default=False, - help='模拟完成后立即关闭环境,不进入等待命令模式' + help=_t('模拟完成后立即关闭环境,不进入等待命令模式', 'Close the environment after the simulation and skip wait-for-command mode') ) args = parser.parse_args() @@ -1527,9 +1704,10 @@ async def main(): _shutdown_event = asyncio.Event() if not os.path.exists(args.config): - print(f"错误: 配置文件不存在: {args.config}") + print(script_message("config_missing", SCRIPT_LOCALE, path=args.config)) sys.exit(1) - + + _load_simulation_dependencies() config = load_config(args.config) simulation_dir = os.path.dirname(args.config) or "." wait_for_commands = not args.no_wait @@ -1543,10 +1721,22 @@ async def main(): reddit_logger = log_manager.get_reddit_logger() log_manager.info("=" * 60) - log_manager.info("OASIS 双平台并行模拟") - log_manager.info(f"配置文件: {args.config}") - log_manager.info(f"模拟ID: {config.get('simulation_id', 'unknown')}") - log_manager.info(f"等待命令模式: {'启用' if wait_for_commands else '禁用'}") + log_manager.info(script_message("runner_title", SCRIPT_LOCALE, platform=_t("双平台并行", "dual-platform parallel"))) + log_manager.info(script_message("config_path", SCRIPT_LOCALE, path=args.config)) + log_manager.info( + script_message( + "simulation_id", + SCRIPT_LOCALE, + simulation_id=config.get('simulation_id', 'unknown'), + ) + ) + log_manager.info( + script_message( + "wait_mode", + SCRIPT_LOCALE, + state=script_message("enabled", SCRIPT_LOCALE) if wait_for_commands else script_message("disabled", SCRIPT_LOCALE), + ) + ) log_manager.info("=" * 60) time_config = config.get("time_config", {}) @@ -1554,20 +1744,28 @@ async def main(): minutes_per_round = time_config.get('minutes_per_round', 30) config_total_rounds = (total_hours * 60) // minutes_per_round - log_manager.info(f"模拟参数:") - log_manager.info(f" - 总模拟时长: {total_hours}小时") - log_manager.info(f" - 每轮时间: {minutes_per_round}分钟") - log_manager.info(f" - 配置总轮数: {config_total_rounds}") + log_manager.info(script_message("simulation_params", SCRIPT_LOCALE)) + log_manager.info(script_message("total_hours", SCRIPT_LOCALE, hours=total_hours)) + log_manager.info(script_message("minutes_per_round", SCRIPT_LOCALE, minutes=minutes_per_round)) + log_manager.info(script_message("total_rounds", SCRIPT_LOCALE, rounds=config_total_rounds)) if args.max_rounds: - log_manager.info(f" - 最大轮数限制: {args.max_rounds}") + log_manager.info(script_message("max_rounds_limit", SCRIPT_LOCALE, max_rounds=args.max_rounds)) if args.max_rounds < config_total_rounds: - log_manager.info(f" - 实际执行轮数: {args.max_rounds} (已截断)") - log_manager.info(f" - Agent数量: {len(config.get('agent_configs', []))}") + log_manager.info( + script_message( + "effective_rounds", + SCRIPT_LOCALE, + rounds=args.max_rounds, + ) + ) + log_manager.info( + script_message("agent_count", SCRIPT_LOCALE, count=len(config.get('agent_configs', []))) + ) - log_manager.info("日志结构:") - log_manager.info(f" - 主日志: simulation.log") - log_manager.info(f" - Twitter动作: twitter/actions.jsonl") - log_manager.info(f" - Reddit动作: reddit/actions.jsonl") + log_manager.info(_t("日志结构:", "Log layout:")) + log_manager.info(_t(" - 主日志: simulation.log", " - Main log: simulation.log")) + log_manager.info(_t(" - Twitter动作: twitter/actions.jsonl", " - Twitter actions: twitter/actions.jsonl")) + log_manager.info(_t(" - Reddit动作: reddit/actions.jsonl", " - Reddit actions: reddit/actions.jsonl")) log_manager.info("=" * 60) start_time = datetime.now() @@ -1590,14 +1788,14 @@ async def main(): total_elapsed = (datetime.now() - start_time).total_seconds() log_manager.info("=" * 60) - log_manager.info(f"模拟循环完成! 总耗时: {total_elapsed:.1f}秒") + log_manager.info(_t(f"模拟循环完成! 总耗时: {total_elapsed:.1f}秒", f"Simulation loop complete! Total elapsed: {total_elapsed:.1f}s")) # 是否进入等待命令模式 if wait_for_commands: log_manager.info("") log_manager.info("=" * 60) - log_manager.info("进入等待命令模式 - 环境保持运行") - log_manager.info("支持的命令: interview, batch_interview, close_env") + log_manager.info(script_message("wait_mode_banner", SCRIPT_LOCALE)) + log_manager.info(script_message("supported_commands", SCRIPT_LOCALE)) log_manager.info("=" * 60) # 创建IPC处理器 @@ -1623,27 +1821,27 @@ async def main(): except asyncio.TimeoutError: pass # 超时继续循环 except KeyboardInterrupt: - print("\n收到中断信号") + print(script_message("interrupt_received", SCRIPT_LOCALE)) except asyncio.CancelledError: - print("\n任务被取消") + print(script_message("task_cancelled", SCRIPT_LOCALE)) except Exception as e: - print(f"\n命令处理出错: {e}") + print(script_message("command_processing_failed", SCRIPT_LOCALE, error=e)) - log_manager.info("\n关闭环境...") + log_manager.info(script_message("closing_env", SCRIPT_LOCALE)) ipc_handler.update_status("stopped") # 关闭环境 if twitter_result and twitter_result.env: await twitter_result.env.close() - log_manager.info("[Twitter] 环境已关闭") + log_manager.info(f"[Twitter] {script_message('env_closed', SCRIPT_LOCALE)}") if reddit_result and reddit_result.env: await reddit_result.env.close() - log_manager.info("[Reddit] 环境已关闭") + log_manager.info(f"[Reddit] {script_message('env_closed', SCRIPT_LOCALE)}") log_manager.info("=" * 60) - log_manager.info(f"全部完成!") - log_manager.info(f"日志文件:") + log_manager.info(_t("全部完成!", "All tasks completed!")) + log_manager.info(_t("日志文件:", "Log files:")) log_manager.info(f" - {os.path.join(simulation_dir, 'simulation.log')}") log_manager.info(f" - {os.path.join(simulation_dir, 'twitter', 'actions.jsonl')}") log_manager.info(f" - {os.path.join(simulation_dir, 'reddit', 'actions.jsonl')}") @@ -1663,7 +1861,7 @@ def setup_signal_handlers(loop=None): def signal_handler(signum, frame): global _cleanup_done sig_name = "SIGTERM" if signum == signal.SIGTERM else "SIGINT" - print(f"\n收到 {sig_name} 信号,正在退出...") + print(script_message("signal_received", SCRIPT_LOCALE, signal_name=sig_name)) if not _cleanup_done: _cleanup_done = True @@ -1674,7 +1872,7 @@ def signal_handler(signum, frame): # 不要直接 sys.exit(),让 asyncio 循环正常退出并清理资源 # 如果是重复收到信号,才强制退出 else: - print("强制退出...") + print(script_message("force_exit", SCRIPT_LOCALE)) sys.exit(1) signal.signal(signal.SIGTERM, signal_handler) @@ -1683,11 +1881,13 @@ def signal_handler(signum, frame): if __name__ == "__main__": setup_signal_handlers() + should_print_exit_message = True try: asyncio.run(main()) except KeyboardInterrupt: - print("\n程序被中断") - except SystemExit: + print(script_message("program_interrupted", SCRIPT_LOCALE)) + except SystemExit as exc: + should_print_exit_message = exc.code not in (0, None) pass finally: # 清理 multiprocessing 资源跟踪器(防止退出时的警告) @@ -1696,4 +1896,5 @@ def signal_handler(signum, frame): resource_tracker._resource_tracker._stop() except Exception: pass - print("模拟进程已退出") + if should_print_exit_message: + print(script_message("process_exited", SCRIPT_LOCALE)) diff --git a/backend/scripts/run_reddit_simulation.py b/backend/scripts/run_reddit_simulation.py index 14907cbd..93db1aa2 100644 --- a/backend/scripts/run_reddit_simulation.py +++ b/backend/scripts/run_reddit_simulation.py @@ -36,18 +36,36 @@ sys.path.insert(0, _scripts_dir) sys.path.insert(0, _backend_dir) -# 加载项目根目录的 .env 文件(包含 LLM_API_KEY 等配置) -from dotenv import load_dotenv -_env_file = os.path.join(_project_root, '.env') -if os.path.exists(_env_file): - load_dotenv(_env_file) -else: - _backend_env = os.path.join(_backend_dir, '.env') - if os.path.exists(_backend_env): - load_dotenv(_backend_env) +SCRIPT_LOCALE = "en" if os.environ.get("MIROFISH_LOCALE", "").lower().startswith("en") else "zh" + + +def _t(zh: str, en: str) -> str: + return en if SCRIPT_LOCALE == "en" else zh import re +from llm_env import ( + apply_openai_compat_env, + load_dotenv_if_available, + missing_api_key_message, + resolve_standard_llm_env, + script_message, +) + + +def _load_env_file() -> None: + """Load a repo .env file when python-dotenv is available.""" + env_file = os.path.join(_project_root, ".env") + if os.path.exists(env_file): + load_dotenv_if_available(env_file) + return + + backend_env = os.path.join(_backend_dir, ".env") + if os.path.exists(backend_env): + load_dotenv_if_available(backend_env) + + +_load_env_file() class UnicodeFormatter(logging.Formatter): @@ -115,20 +133,45 @@ def setup_oasis_logging(log_dir: str): logger.propagate = False -try: - from camel.models import ModelFactory - from camel.types import ModelPlatformType - import oasis - from oasis import ( - ActionType, - LLMAction, - ManualAction, - generate_reddit_agent_graph - ) -except ImportError as e: - print(f"错误: 缺少依赖 {e}") - print("请先安装: pip install oasis-ai camel-ai") - sys.exit(1) +ModelFactory = None +ModelPlatformType = None +ActionType = None +LLMAction = None +ManualAction = None +generate_reddit_agent_graph = None +oasis = None + + +def _load_simulation_dependencies() -> None: + global ModelFactory, ModelPlatformType, ActionType, LLMAction, ManualAction + global generate_reddit_agent_graph, oasis + + if ModelFactory is not None: + return + + try: + from camel.models import ModelFactory as _ModelFactory + from camel.types import ModelPlatformType as _ModelPlatformType + import oasis as _oasis + from oasis import ( + ActionType as _ActionType, + LLMAction as _LLMAction, + ManualAction as _ManualAction, + generate_reddit_agent_graph as _generate_reddit_agent_graph, + ) + except ImportError as e: + print(script_message("missing_dependency", SCRIPT_LOCALE, dependency=e)) + print(script_message("install_simulation_deps_npm", SCRIPT_LOCALE)) + print(script_message("install_simulation_deps_uv", SCRIPT_LOCALE)) + sys.exit(1) + + ModelFactory = _ModelFactory + ModelPlatformType = _ModelPlatformType + ActionType = _ActionType + LLMAction = _LLMAction + ManualAction = _ManualAction + generate_reddit_agent_graph = _generate_reddit_agent_graph + oasis = _oasis # IPC相关常量 @@ -236,12 +279,19 @@ async def handle_interview(self, command_id: str, agent_id: int, prompt: str) -> result = self._get_interview_result(agent_id) self.send_response(command_id, "completed", result=result) - print(f" Interview完成: agent_id={agent_id}") + print(script_message("interview_completed", SCRIPT_LOCALE, agent_id=agent_id)) return True except Exception as e: error_msg = str(e) - print(f" Interview失败: agent_id={agent_id}, error={error_msg}") + print( + script_message( + "interview_failed", + SCRIPT_LOCALE, + agent_id=agent_id, + error=error_msg, + ) + ) self.send_response(command_id, "failed", error=error_msg) return False @@ -269,10 +319,14 @@ async def handle_batch_interview(self, command_id: str, interviews: List[Dict]) ) agent_prompts[agent_id] = prompt except Exception as e: - print(f" 警告: 无法获取Agent {agent_id}: {e}") + print(script_message("agent_lookup_warning", SCRIPT_LOCALE, agent_id=agent_id, error=e)) if not actions: - self.send_response(command_id, "failed", error="没有有效的Agent") + self.send_response( + command_id, + "failed", + error=script_message("no_valid_agents", SCRIPT_LOCALE), + ) return False # 执行批量Interview @@ -288,12 +342,12 @@ async def handle_batch_interview(self, command_id: str, interviews: List[Dict]) "interviews_count": len(results), "results": results }) - print(f" 批量Interview完成: {len(results)} 个Agent") + print(script_message("batch_interview_completed", SCRIPT_LOCALE, count=len(results))) return True except Exception as e: error_msg = str(e) - print(f" 批量Interview失败: {error_msg}") + print(script_message("batch_interview_failed", SCRIPT_LOCALE, error=error_msg)) self.send_response(command_id, "failed", error=error_msg) return False @@ -336,7 +390,7 @@ def _get_interview_result(self, agent_id: int) -> Dict[str, Any]: conn.close() except Exception as e: - print(f" 读取Interview结果失败: {e}") + print(script_message("interview_result_read_failed", SCRIPT_LOCALE, error=e)) return result @@ -355,7 +409,7 @@ async def process_commands(self) -> bool: command_type = command.get("command_type") args = command.get("args", {}) - print(f"\n收到IPC命令: {command_type}, id={command_id}") + print(script_message("ipc_command_received", SCRIPT_LOCALE, command_type=command_type, command_id=command_id)) if command_type == CommandType.INTERVIEW: await self.handle_interview( @@ -373,35 +427,30 @@ async def process_commands(self) -> bool: return True elif command_type == CommandType.CLOSE_ENV: - print("收到关闭环境命令") - self.send_response(command_id, "completed", result={"message": "环境即将关闭"}) + print(script_message("close_command_received", SCRIPT_LOCALE)) + self.send_response( + command_id, + "completed", + result={"message": script_message("close_command_ack", SCRIPT_LOCALE)}, + ) return False - + else: - self.send_response(command_id, "failed", error=f"未知命令类型: {command_type}") + self.send_response( + command_id, + "failed", + error=script_message( + "unknown_command", + SCRIPT_LOCALE, + command_type=command_type, + ), + ) return True class RedditSimulationRunner: """Reddit模拟运行器""" - - # Reddit可用动作(不包含INTERVIEW,INTERVIEW只能通过ManualAction手动触发) - AVAILABLE_ACTIONS = [ - ActionType.LIKE_POST, - ActionType.DISLIKE_POST, - ActionType.CREATE_POST, - ActionType.CREATE_COMMENT, - ActionType.LIKE_COMMENT, - ActionType.DISLIKE_COMMENT, - ActionType.SEARCH_POSTS, - ActionType.SEARCH_USER, - ActionType.TREND, - ActionType.REFRESH, - ActionType.DO_NOTHING, - ActionType.FOLLOW, - ActionType.MUTE, - ] - + def __init__(self, config_path: str, wait_for_commands: bool = True): """ 初始化模拟运行器 @@ -410,6 +459,7 @@ def __init__(self, config_path: str, wait_for_commands: bool = True): config_path: 配置文件路径 (simulation_config.json) wait_for_commands: 模拟完成后是否等待命令(默认True) """ + _load_simulation_dependencies() self.config_path = config_path self.config = self._load_config() self.simulation_dir = os.path.dirname(config_path) @@ -430,36 +480,55 @@ def _get_profile_path(self) -> str: def _get_db_path(self) -> str: """获取数据库路径""" return os.path.join(self.simulation_dir, "reddit_simulation.db") + + @staticmethod + def available_actions(): + return [ + ActionType.LIKE_POST, + ActionType.DISLIKE_POST, + ActionType.CREATE_POST, + ActionType.CREATE_COMMENT, + ActionType.LIKE_COMMENT, + ActionType.DISLIKE_COMMENT, + ActionType.SEARCH_POSTS, + ActionType.SEARCH_USER, + ActionType.TREND, + ActionType.REFRESH, + ActionType.DO_NOTHING, + ActionType.FOLLOW, + ActionType.MUTE, + ] def _create_model(self): """ 创建LLM模型 统一使用项目根目录 .env 文件中的配置(优先级最高): - - LLM_API_KEY: API密钥 - - LLM_BASE_URL: API基础URL - - LLM_MODEL_NAME: 模型名称 + - LLM_API_KEY / OPENAI_API_KEY: API密钥 + - LLM_BASE_URL / OPENAI_BASE_URL / OPENAI_API_BASE_URL: API基础URL + - LLM_MODEL_NAME / OPENAI_MODEL: 模型名称 """ # 优先从 .env 读取配置 - llm_api_key = os.environ.get("LLM_API_KEY", "") - llm_base_url = os.environ.get("LLM_BASE_URL", "") - llm_model = os.environ.get("LLM_MODEL_NAME", "") + llm_api_key, llm_base_url, llm_model = resolve_standard_llm_env() # 如果 .env 中没有,则使用 config 作为备用 if not llm_model: llm_model = self.config.get("llm_model", "gpt-4o-mini") # 设置 camel-ai 所需的环境变量 - if llm_api_key: - os.environ["OPENAI_API_KEY"] = llm_api_key + apply_openai_compat_env(llm_api_key, llm_base_url, llm_model) if not os.environ.get("OPENAI_API_KEY"): - raise ValueError("缺少 API Key 配置,请在项目根目录 .env 文件中设置 LLM_API_KEY") - - if llm_base_url: - os.environ["OPENAI_API_BASE_URL"] = llm_base_url - - print(f"LLM配置: model={llm_model}, base_url={llm_base_url[:40] if llm_base_url else '默认'}...") + raise ValueError(missing_api_key_message(SCRIPT_LOCALE)) + + print( + script_message( + "llm_config", + SCRIPT_LOCALE, + model=llm_model, + base_url=llm_base_url[:40] if llm_base_url else script_message("default_base_url", SCRIPT_LOCALE), + ) + ) return ModelFactory.create( model_platform=ModelPlatformType.OPENAI, @@ -527,10 +596,16 @@ async def run(self, max_rounds: int = None): max_rounds: 最大模拟轮数(可选,用于截断过长的模拟) """ print("=" * 60) - print("OASIS Reddit模拟") - print(f"配置文件: {self.config_path}") - print(f"模拟ID: {self.config.get('simulation_id', 'unknown')}") - print(f"等待命令模式: {'启用' if self.wait_for_commands else '禁用'}") + print(script_message("runner_title", SCRIPT_LOCALE, platform="Reddit")) + print(script_message("config_path", SCRIPT_LOCALE, path=self.config_path)) + print(script_message("simulation_id", SCRIPT_LOCALE, simulation_id=self.config.get('simulation_id', 'unknown'))) + print( + script_message( + "wait_mode", + SCRIPT_LOCALE, + state=script_message("enabled" if self.wait_for_commands else "disabled", SCRIPT_LOCALE), + ) + ) print("=" * 60) time_config = self.config.get("time_config", {}) @@ -543,37 +618,37 @@ async def run(self, max_rounds: int = None): original_rounds = total_rounds total_rounds = min(total_rounds, max_rounds) if total_rounds < original_rounds: - print(f"\n轮数已截断: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})") - - print(f"\n模拟参数:") - print(f" - 总模拟时长: {total_hours}小时") - print(f" - 每轮时间: {minutes_per_round}分钟") - print(f" - 总轮数: {total_rounds}") + print(script_message("rounds_truncated", SCRIPT_LOCALE, original=original_rounds, current=total_rounds, max_rounds=max_rounds)) + + print(script_message("simulation_params", SCRIPT_LOCALE)) + print(script_message("total_hours", SCRIPT_LOCALE, hours=total_hours)) + print(script_message("minutes_per_round", SCRIPT_LOCALE, minutes=minutes_per_round)) + print(script_message("total_rounds", SCRIPT_LOCALE, rounds=total_rounds)) if max_rounds: - print(f" - 最大轮数限制: {max_rounds}") - print(f" - Agent数量: {len(self.config.get('agent_configs', []))}") + print(script_message("max_rounds_limit", SCRIPT_LOCALE, max_rounds=max_rounds)) + print(script_message("agent_count", SCRIPT_LOCALE, count=len(self.config.get('agent_configs', [])))) - print("\n初始化LLM模型...") + print(script_message("init_model", SCRIPT_LOCALE)) model = self._create_model() - print("加载Agent Profile...") + print(script_message("load_profiles", SCRIPT_LOCALE)) profile_path = self._get_profile_path() if not os.path.exists(profile_path): - print(f"错误: Profile文件不存在: {profile_path}") + print(script_message("profile_missing", SCRIPT_LOCALE, path=profile_path)) return self.agent_graph = await generate_reddit_agent_graph( profile_path=profile_path, model=model, - available_actions=self.AVAILABLE_ACTIONS, + available_actions=self.available_actions(), ) db_path = self._get_db_path() if os.path.exists(db_path): os.remove(db_path) - print(f"已删除旧数据库: {db_path}") - - print("创建OASIS环境...") + print(script_message("old_db_removed", SCRIPT_LOCALE, path=db_path)) + + print(script_message("creating_oasis_env", SCRIPT_LOCALE)) self.env = oasis.make( agent_graph=self.agent_graph, platform=oasis.DefaultPlatformType.REDDIT, @@ -582,7 +657,7 @@ async def run(self, max_rounds: int = None): ) await self.env.reset() - print("环境初始化完成\n") + print(script_message("env_initialized", SCRIPT_LOCALE)) # 初始化IPC处理器 self.ipc_handler = IPCHandler(self.simulation_dir, self.env, self.agent_graph) @@ -591,9 +666,9 @@ async def run(self, max_rounds: int = None): # 执行初始事件 event_config = self.config.get("event_config", {}) initial_posts = event_config.get("initial_posts", []) - + if initial_posts: - print(f"执行初始事件 ({len(initial_posts)}条初始帖子)...") + print(script_message("initial_events_start", SCRIPT_LOCALE, count=len(initial_posts))) initial_actions = {} for post in initial_posts: agent_id = post.get("poster_agent_id", 0) @@ -613,14 +688,14 @@ async def run(self, max_rounds: int = None): action_args={"content": content} ) except Exception as e: - print(f" 警告: 无法为Agent {agent_id}创建初始帖子: {e}") - + print(script_message("initial_post_warning", SCRIPT_LOCALE, agent_id=agent_id, error=e)) + if initial_actions: await self.env.step(initial_actions) - print(f" 已发布 {len(initial_actions)} 条初始帖子") - + print(script_message("initial_posts_published", SCRIPT_LOCALE, count=len(initial_actions))) + # 主模拟循环 - print("\n开始模拟循环...") + print(script_message("simulation_loop_start", SCRIPT_LOCALE)) start_time = datetime.now() for round_num in range(total_rounds): @@ -645,21 +720,30 @@ async def run(self, max_rounds: int = None): if (round_num + 1) % 10 == 0 or round_num == 0: elapsed = (datetime.now() - start_time).total_seconds() progress = (round_num + 1) / total_rounds * 100 - print(f" [Day {simulated_day}, {simulated_hour:02d}:00] " - f"Round {round_num + 1}/{total_rounds} ({progress:.1f}%) " - f"- {len(active_agents)} agents active " - f"- elapsed: {elapsed:.1f}s") + print( + script_message( + "round_progress", + SCRIPT_LOCALE, + day=simulated_day, + hour=simulated_hour, + round=round_num + 1, + total_rounds=total_rounds, + progress=progress, + agent_count=len(active_agents), + elapsed=elapsed, + ) + ) total_elapsed = (datetime.now() - start_time).total_seconds() - print(f"\n模拟循环完成!") - print(f" - 总耗时: {total_elapsed:.1f}秒") - print(f" - 数据库: {db_path}") + print(script_message("simulation_loop_complete", SCRIPT_LOCALE)) + print(script_message("total_elapsed", SCRIPT_LOCALE, seconds=total_elapsed)) + print(script_message("database_path", SCRIPT_LOCALE, path=db_path)) # 是否进入等待命令模式 if self.wait_for_commands: print("\n" + "=" * 60) - print("进入等待命令模式 - 环境保持运行") - print("支持的命令: interview, batch_interview, close_env") + print(script_message("wait_mode_banner", SCRIPT_LOCALE)) + print(script_message("supported_commands", SCRIPT_LOCALE)) print("=" * 60) self.ipc_handler.update_status("alive") @@ -676,41 +760,43 @@ async def run(self, max_rounds: int = None): except asyncio.TimeoutError: pass except KeyboardInterrupt: - print("\n收到中断信号") + print(script_message("interrupt_received", SCRIPT_LOCALE)) except asyncio.CancelledError: - print("\n任务被取消") + print(script_message("task_cancelled", SCRIPT_LOCALE)) except Exception as e: - print(f"\n命令处理出错: {e}") - - print("\n关闭环境...") + print(script_message("command_processing_failed", SCRIPT_LOCALE, error=e)) + + print(script_message("closing_env", SCRIPT_LOCALE)) # 关闭环境 self.ipc_handler.update_status("stopped") await self.env.close() - print("环境已关闭") + print(script_message("env_closed", SCRIPT_LOCALE)) print("=" * 60) async def main(): - parser = argparse.ArgumentParser(description='OASIS Reddit模拟') + parser = argparse.ArgumentParser( + description=script_message("runner_title", SCRIPT_LOCALE, platform="Reddit") + ) parser.add_argument( '--config', type=str, required=True, - help='配置文件路径 (simulation_config.json)' + help=script_message("cli_config_help", SCRIPT_LOCALE) ) parser.add_argument( '--max-rounds', type=int, default=None, - help='最大模拟轮数(可选,用于截断过长的模拟)' + help=script_message("cli_max_rounds_help", SCRIPT_LOCALE) ) parser.add_argument( '--no-wait', action='store_true', default=False, - help='模拟完成后立即关闭环境,不进入等待命令模式' + help=script_message("cli_no_wait_help", SCRIPT_LOCALE) ) args = parser.parse_args() @@ -720,7 +806,7 @@ async def main(): _shutdown_event = asyncio.Event() if not os.path.exists(args.config): - print(f"错误: 配置文件不存在: {args.config}") + print(script_message("config_missing", SCRIPT_LOCALE, path=args.config)) sys.exit(1) # 初始化日志配置(使用固定文件名,清理旧日志) @@ -742,14 +828,14 @@ def setup_signal_handlers(): def signal_handler(signum, frame): global _cleanup_done sig_name = "SIGTERM" if signum == signal.SIGTERM else "SIGINT" - print(f"\n收到 {sig_name} 信号,正在退出...") + print(script_message("signal_received", SCRIPT_LOCALE, signal_name=sig_name)) if not _cleanup_done: _cleanup_done = True if _shutdown_event: _shutdown_event.set() else: # 重复收到信号才强制退出 - print("强制退出...") + print(script_message("force_exit", SCRIPT_LOCALE)) sys.exit(1) signal.signal(signal.SIGTERM, signal_handler) @@ -758,12 +844,14 @@ def signal_handler(signum, frame): if __name__ == "__main__": setup_signal_handlers() + should_print_exit_message = True try: asyncio.run(main()) except KeyboardInterrupt: - print("\n程序被中断") - except SystemExit: + print(script_message("program_interrupted", SCRIPT_LOCALE)) + except SystemExit as exc: + should_print_exit_message = exc.code not in (0, None) pass finally: - print("模拟进程已退出") - + if should_print_exit_message: + print(script_message("process_exited", SCRIPT_LOCALE)) diff --git a/backend/scripts/run_twitter_simulation.py b/backend/scripts/run_twitter_simulation.py index caab9e9d..ec16860a 100644 --- a/backend/scripts/run_twitter_simulation.py +++ b/backend/scripts/run_twitter_simulation.py @@ -36,18 +36,36 @@ sys.path.insert(0, _scripts_dir) sys.path.insert(0, _backend_dir) -# 加载项目根目录的 .env 文件(包含 LLM_API_KEY 等配置) -from dotenv import load_dotenv -_env_file = os.path.join(_project_root, '.env') -if os.path.exists(_env_file): - load_dotenv(_env_file) -else: - _backend_env = os.path.join(_backend_dir, '.env') - if os.path.exists(_backend_env): - load_dotenv(_backend_env) +SCRIPT_LOCALE = "en" if os.environ.get("MIROFISH_LOCALE", "").lower().startswith("en") else "zh" + + +def _t(zh: str, en: str) -> str: + return en if SCRIPT_LOCALE == "en" else zh import re +from llm_env import ( + apply_openai_compat_env, + load_dotenv_if_available, + missing_api_key_message, + resolve_standard_llm_env, + script_message, +) + + +def _load_env_file() -> None: + """Load a repo .env file when python-dotenv is available.""" + env_file = os.path.join(_project_root, ".env") + if os.path.exists(env_file): + load_dotenv_if_available(env_file) + return + + backend_env = os.path.join(_backend_dir, ".env") + if os.path.exists(backend_env): + load_dotenv_if_available(backend_env) + + +_load_env_file() class UnicodeFormatter(logging.Formatter): @@ -115,20 +133,45 @@ def setup_oasis_logging(log_dir: str): logger.propagate = False -try: - from camel.models import ModelFactory - from camel.types import ModelPlatformType - import oasis - from oasis import ( - ActionType, - LLMAction, - ManualAction, - generate_twitter_agent_graph - ) -except ImportError as e: - print(f"错误: 缺少依赖 {e}") - print("请先安装: pip install oasis-ai camel-ai") - sys.exit(1) +ModelFactory = None +ModelPlatformType = None +ActionType = None +LLMAction = None +ManualAction = None +generate_twitter_agent_graph = None +oasis = None + + +def _load_simulation_dependencies() -> None: + global ModelFactory, ModelPlatformType, ActionType, LLMAction, ManualAction + global generate_twitter_agent_graph, oasis + + if ModelFactory is not None: + return + + try: + from camel.models import ModelFactory as _ModelFactory + from camel.types import ModelPlatformType as _ModelPlatformType + import oasis as _oasis + from oasis import ( + ActionType as _ActionType, + LLMAction as _LLMAction, + ManualAction as _ManualAction, + generate_twitter_agent_graph as _generate_twitter_agent_graph, + ) + except ImportError as e: + print(script_message("missing_dependency", SCRIPT_LOCALE, dependency=e)) + print(script_message("install_simulation_deps_npm", SCRIPT_LOCALE)) + print(script_message("install_simulation_deps_uv", SCRIPT_LOCALE)) + sys.exit(1) + + ModelFactory = _ModelFactory + ModelPlatformType = _ModelPlatformType + ActionType = _ActionType + LLMAction = _LLMAction + ManualAction = _ManualAction + generate_twitter_agent_graph = _generate_twitter_agent_graph + oasis = _oasis # IPC相关常量 @@ -236,12 +279,19 @@ async def handle_interview(self, command_id: str, agent_id: int, prompt: str) -> result = self._get_interview_result(agent_id) self.send_response(command_id, "completed", result=result) - print(f" Interview完成: agent_id={agent_id}") + print(script_message("interview_completed", SCRIPT_LOCALE, agent_id=agent_id)) return True except Exception as e: error_msg = str(e) - print(f" Interview失败: agent_id={agent_id}, error={error_msg}") + print( + script_message( + "interview_failed", + SCRIPT_LOCALE, + agent_id=agent_id, + error=error_msg, + ) + ) self.send_response(command_id, "failed", error=error_msg) return False @@ -269,10 +319,14 @@ async def handle_batch_interview(self, command_id: str, interviews: List[Dict]) ) agent_prompts[agent_id] = prompt except Exception as e: - print(f" 警告: 无法获取Agent {agent_id}: {e}") + print(script_message("agent_lookup_warning", SCRIPT_LOCALE, agent_id=agent_id, error=e)) if not actions: - self.send_response(command_id, "failed", error="没有有效的Agent") + self.send_response( + command_id, + "failed", + error=script_message("no_valid_agents", SCRIPT_LOCALE), + ) return False # 执行批量Interview @@ -288,12 +342,12 @@ async def handle_batch_interview(self, command_id: str, interviews: List[Dict]) "interviews_count": len(results), "results": results }) - print(f" 批量Interview完成: {len(results)} 个Agent") + print(script_message("batch_interview_completed", SCRIPT_LOCALE, count=len(results))) return True except Exception as e: error_msg = str(e) - print(f" 批量Interview失败: {error_msg}") + print(script_message("batch_interview_failed", SCRIPT_LOCALE, error=error_msg)) self.send_response(command_id, "failed", error=error_msg) return False @@ -336,7 +390,7 @@ def _get_interview_result(self, agent_id: int) -> Dict[str, Any]: conn.close() except Exception as e: - print(f" 读取Interview结果失败: {e}") + print(script_message("interview_result_read_failed", SCRIPT_LOCALE, error=e)) return result @@ -355,7 +409,7 @@ async def process_commands(self) -> bool: command_type = command.get("command_type") args = command.get("args", {}) - print(f"\n收到IPC命令: {command_type}, id={command_id}") + print(script_message("ipc_command_received", SCRIPT_LOCALE, command_type=command_type, command_id=command_id)) if command_type == CommandType.INTERVIEW: await self.handle_interview( @@ -373,28 +427,30 @@ async def process_commands(self) -> bool: return True elif command_type == CommandType.CLOSE_ENV: - print("收到关闭环境命令") - self.send_response(command_id, "completed", result={"message": "环境即将关闭"}) + print(script_message("close_command_received", SCRIPT_LOCALE)) + self.send_response( + command_id, + "completed", + result={"message": script_message("close_command_ack", SCRIPT_LOCALE)}, + ) return False - + else: - self.send_response(command_id, "failed", error=f"未知命令类型: {command_type}") + self.send_response( + command_id, + "failed", + error=script_message( + "unknown_command", + SCRIPT_LOCALE, + command_type=command_type, + ), + ) return True class TwitterSimulationRunner: """Twitter模拟运行器""" - - # Twitter可用动作(不包含INTERVIEW,INTERVIEW只能通过ManualAction手动触发) - AVAILABLE_ACTIONS = [ - ActionType.CREATE_POST, - ActionType.LIKE_POST, - ActionType.REPOST, - ActionType.FOLLOW, - ActionType.DO_NOTHING, - ActionType.QUOTE_POST, - ] - + def __init__(self, config_path: str, wait_for_commands: bool = True): """ 初始化模拟运行器 @@ -403,6 +459,7 @@ def __init__(self, config_path: str, wait_for_commands: bool = True): config_path: 配置文件路径 (simulation_config.json) wait_for_commands: 模拟完成后是否等待命令(默认True) """ + _load_simulation_dependencies() self.config_path = config_path self.config = self._load_config() self.simulation_dir = os.path.dirname(config_path) @@ -423,36 +480,48 @@ def _get_profile_path(self) -> str: def _get_db_path(self) -> str: """获取数据库路径""" return os.path.join(self.simulation_dir, "twitter_simulation.db") + + @staticmethod + def available_actions(): + return [ + ActionType.CREATE_POST, + ActionType.LIKE_POST, + ActionType.REPOST, + ActionType.FOLLOW, + ActionType.DO_NOTHING, + ActionType.QUOTE_POST, + ] def _create_model(self): """ 创建LLM模型 统一使用项目根目录 .env 文件中的配置(优先级最高): - - LLM_API_KEY: API密钥 - - LLM_BASE_URL: API基础URL - - LLM_MODEL_NAME: 模型名称 + - LLM_API_KEY / OPENAI_API_KEY: API密钥 + - LLM_BASE_URL / OPENAI_BASE_URL / OPENAI_API_BASE_URL: API基础URL + - LLM_MODEL_NAME / OPENAI_MODEL: 模型名称 """ # 优先从 .env 读取配置 - llm_api_key = os.environ.get("LLM_API_KEY", "") - llm_base_url = os.environ.get("LLM_BASE_URL", "") - llm_model = os.environ.get("LLM_MODEL_NAME", "") + llm_api_key, llm_base_url, llm_model = resolve_standard_llm_env() # 如果 .env 中没有,则使用 config 作为备用 if not llm_model: llm_model = self.config.get("llm_model", "gpt-4o-mini") # 设置 camel-ai 所需的环境变量 - if llm_api_key: - os.environ["OPENAI_API_KEY"] = llm_api_key + apply_openai_compat_env(llm_api_key, llm_base_url, llm_model) if not os.environ.get("OPENAI_API_KEY"): - raise ValueError("缺少 API Key 配置,请在项目根目录 .env 文件中设置 LLM_API_KEY") - - if llm_base_url: - os.environ["OPENAI_API_BASE_URL"] = llm_base_url - - print(f"LLM配置: model={llm_model}, base_url={llm_base_url[:40] if llm_base_url else '默认'}...") + raise ValueError(missing_api_key_message(SCRIPT_LOCALE)) + + print( + script_message( + "llm_config", + SCRIPT_LOCALE, + model=llm_model, + base_url=llm_base_url[:40] if llm_base_url else script_message("default_base_url", SCRIPT_LOCALE), + ) + ) return ModelFactory.create( model_platform=ModelPlatformType.OPENAI, @@ -535,10 +604,16 @@ async def run(self, max_rounds: int = None): max_rounds: 最大模拟轮数(可选,用于截断过长的模拟) """ print("=" * 60) - print("OASIS Twitter模拟") - print(f"配置文件: {self.config_path}") - print(f"模拟ID: {self.config.get('simulation_id', 'unknown')}") - print(f"等待命令模式: {'启用' if self.wait_for_commands else '禁用'}") + print(script_message("runner_title", SCRIPT_LOCALE, platform="Twitter")) + print(script_message("config_path", SCRIPT_LOCALE, path=self.config_path)) + print(script_message("simulation_id", SCRIPT_LOCALE, simulation_id=self.config.get('simulation_id', 'unknown'))) + print( + script_message( + "wait_mode", + SCRIPT_LOCALE, + state=script_message("enabled" if self.wait_for_commands else "disabled", SCRIPT_LOCALE), + ) + ) print("=" * 60) # 加载时间配置 @@ -554,41 +629,41 @@ async def run(self, max_rounds: int = None): original_rounds = total_rounds total_rounds = min(total_rounds, max_rounds) if total_rounds < original_rounds: - print(f"\n轮数已截断: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})") - - print(f"\n模拟参数:") - print(f" - 总模拟时长: {total_hours}小时") - print(f" - 每轮时间: {minutes_per_round}分钟") - print(f" - 总轮数: {total_rounds}") + print(script_message("rounds_truncated", SCRIPT_LOCALE, original=original_rounds, current=total_rounds, max_rounds=max_rounds)) + + print(script_message("simulation_params", SCRIPT_LOCALE)) + print(script_message("total_hours", SCRIPT_LOCALE, hours=total_hours)) + print(script_message("minutes_per_round", SCRIPT_LOCALE, minutes=minutes_per_round)) + print(script_message("total_rounds", SCRIPT_LOCALE, rounds=total_rounds)) if max_rounds: - print(f" - 最大轮数限制: {max_rounds}") - print(f" - Agent数量: {len(self.config.get('agent_configs', []))}") + print(script_message("max_rounds_limit", SCRIPT_LOCALE, max_rounds=max_rounds)) + print(script_message("agent_count", SCRIPT_LOCALE, count=len(self.config.get('agent_configs', [])))) # 创建模型 - print("\n初始化LLM模型...") + print(script_message("init_model", SCRIPT_LOCALE)) model = self._create_model() # 加载Agent图 - print("加载Agent Profile...") + print(script_message("load_profiles", SCRIPT_LOCALE)) profile_path = self._get_profile_path() if not os.path.exists(profile_path): - print(f"错误: Profile文件不存在: {profile_path}") + print(script_message("profile_missing", SCRIPT_LOCALE, path=profile_path)) return self.agent_graph = await generate_twitter_agent_graph( profile_path=profile_path, model=model, - available_actions=self.AVAILABLE_ACTIONS, + available_actions=self.available_actions(), ) # 数据库路径 db_path = self._get_db_path() if os.path.exists(db_path): os.remove(db_path) - print(f"已删除旧数据库: {db_path}") - + print(script_message("old_db_removed", SCRIPT_LOCALE, path=db_path)) + # 创建环境 - print("创建OASIS环境...") + print(script_message("creating_oasis_env", SCRIPT_LOCALE)) self.env = oasis.make( agent_graph=self.agent_graph, platform=oasis.DefaultPlatformType.TWITTER, @@ -597,7 +672,7 @@ async def run(self, max_rounds: int = None): ) await self.env.reset() - print("环境初始化完成\n") + print(script_message("env_initialized", SCRIPT_LOCALE)) # 初始化IPC处理器 self.ipc_handler = IPCHandler(self.simulation_dir, self.env, self.agent_graph) @@ -606,9 +681,9 @@ async def run(self, max_rounds: int = None): # 执行初始事件 event_config = self.config.get("event_config", {}) initial_posts = event_config.get("initial_posts", []) - + if initial_posts: - print(f"执行初始事件 ({len(initial_posts)}条初始帖子)...") + print(script_message("initial_events_start", SCRIPT_LOCALE, count=len(initial_posts))) initial_actions = {} for post in initial_posts: agent_id = post.get("poster_agent_id", 0) @@ -620,14 +695,14 @@ async def run(self, max_rounds: int = None): action_args={"content": content} ) except Exception as e: - print(f" 警告: 无法为Agent {agent_id}创建初始帖子: {e}") - + print(script_message("initial_post_warning", SCRIPT_LOCALE, agent_id=agent_id, error=e)) + if initial_actions: await self.env.step(initial_actions) - print(f" 已发布 {len(initial_actions)} 条初始帖子") - + print(script_message("initial_posts_published", SCRIPT_LOCALE, count=len(initial_actions))) + # 主模拟循环 - print("\n开始模拟循环...") + print(script_message("simulation_loop_start", SCRIPT_LOCALE)) start_time = datetime.now() for round_num in range(total_rounds): @@ -657,21 +732,30 @@ async def run(self, max_rounds: int = None): if (round_num + 1) % 10 == 0 or round_num == 0: elapsed = (datetime.now() - start_time).total_seconds() progress = (round_num + 1) / total_rounds * 100 - print(f" [Day {simulated_day}, {simulated_hour:02d}:00] " - f"Round {round_num + 1}/{total_rounds} ({progress:.1f}%) " - f"- {len(active_agents)} agents active " - f"- elapsed: {elapsed:.1f}s") + print( + script_message( + "round_progress", + SCRIPT_LOCALE, + day=simulated_day, + hour=simulated_hour, + round=round_num + 1, + total_rounds=total_rounds, + progress=progress, + agent_count=len(active_agents), + elapsed=elapsed, + ) + ) total_elapsed = (datetime.now() - start_time).total_seconds() - print(f"\n模拟循环完成!") - print(f" - 总耗时: {total_elapsed:.1f}秒") - print(f" - 数据库: {db_path}") + print(script_message("simulation_loop_complete", SCRIPT_LOCALE)) + print(script_message("total_elapsed", SCRIPT_LOCALE, seconds=total_elapsed)) + print(script_message("database_path", SCRIPT_LOCALE, path=db_path)) # 是否进入等待命令模式 if self.wait_for_commands: print("\n" + "=" * 60) - print("进入等待命令模式 - 环境保持运行") - print("支持的命令: interview, batch_interview, close_env") + print(script_message("wait_mode_banner", SCRIPT_LOCALE)) + print(script_message("supported_commands", SCRIPT_LOCALE)) print("=" * 60) self.ipc_handler.update_status("alive") @@ -688,41 +772,43 @@ async def run(self, max_rounds: int = None): except asyncio.TimeoutError: pass except KeyboardInterrupt: - print("\n收到中断信号") + print(script_message("interrupt_received", SCRIPT_LOCALE)) except asyncio.CancelledError: - print("\n任务被取消") + print(script_message("task_cancelled", SCRIPT_LOCALE)) except Exception as e: - print(f"\n命令处理出错: {e}") - - print("\n关闭环境...") + print(script_message("command_processing_failed", SCRIPT_LOCALE, error=e)) + + print(script_message("closing_env", SCRIPT_LOCALE)) # 关闭环境 self.ipc_handler.update_status("stopped") await self.env.close() - print("环境已关闭") + print(script_message("env_closed", SCRIPT_LOCALE)) print("=" * 60) async def main(): - parser = argparse.ArgumentParser(description='OASIS Twitter模拟') + parser = argparse.ArgumentParser( + description=script_message("runner_title", SCRIPT_LOCALE, platform="Twitter") + ) parser.add_argument( '--config', type=str, required=True, - help='配置文件路径 (simulation_config.json)' + help=script_message("cli_config_help", SCRIPT_LOCALE) ) parser.add_argument( '--max-rounds', type=int, default=None, - help='最大模拟轮数(可选,用于截断过长的模拟)' + help=script_message("cli_max_rounds_help", SCRIPT_LOCALE) ) parser.add_argument( '--no-wait', action='store_true', default=False, - help='模拟完成后立即关闭环境,不进入等待命令模式' + help=script_message("cli_no_wait_help", SCRIPT_LOCALE) ) args = parser.parse_args() @@ -732,7 +818,7 @@ async def main(): _shutdown_event = asyncio.Event() if not os.path.exists(args.config): - print(f"错误: 配置文件不存在: {args.config}") + print(script_message("config_missing", SCRIPT_LOCALE, path=args.config)) sys.exit(1) # 初始化日志配置(使用固定文件名,清理旧日志) @@ -754,14 +840,14 @@ def setup_signal_handlers(): def signal_handler(signum, frame): global _cleanup_done sig_name = "SIGTERM" if signum == signal.SIGTERM else "SIGINT" - print(f"\n收到 {sig_name} 信号,正在退出...") + print(script_message("signal_received", SCRIPT_LOCALE, signal_name=sig_name)) if not _cleanup_done: _cleanup_done = True if _shutdown_event: _shutdown_event.set() else: # 重复收到信号才强制退出 - print("强制退出...") + print(script_message("force_exit", SCRIPT_LOCALE)) sys.exit(1) signal.signal(signal.SIGTERM, signal_handler) @@ -770,11 +856,14 @@ def signal_handler(signum, frame): if __name__ == "__main__": setup_signal_handlers() + should_print_exit_message = True try: asyncio.run(main()) except KeyboardInterrupt: - print("\n程序被中断") - except SystemExit: + print(script_message("program_interrupted", SCRIPT_LOCALE)) + except SystemExit as exc: + should_print_exit_message = exc.code not in (0, None) pass finally: - print("模拟进程已退出") + if should_print_exit_message: + print(script_message("process_exited", SCRIPT_LOCALE)) diff --git a/backend/scripts/test_profile_format.py b/backend/scripts/test_profile_format.py index 354e8b5c..18f7d8d1 100644 --- a/backend/scripts/test_profile_format.py +++ b/backend/scripts/test_profile_format.py @@ -62,6 +62,7 @@ def test_profile_formats(): ] generator = OasisProfileGenerator.__new__(OasisProfileGenerator) + generator.locale = "en" # 使用临时目录 with tempfile.TemporaryDirectory() as temp_dir: @@ -86,13 +87,14 @@ def test_profile_formats(): print(f" {key}: {value}") # 验证必需字段 - required_twitter_fields = ['user_id', 'user_name', 'name', 'bio', - 'friend_count', 'follower_count', 'statuses_count', 'created_at'] + required_twitter_fields = ['user_id', 'name', 'username', 'user_char', 'description'] missing = set(required_twitter_fields) - set(rows[0].keys()) if missing: print(f"\n [错误] 缺少字段: {missing}") else: print(f"\n [通过] 所有必需字段都存在") + assert rows[0]["username"] == "test_user_123" + assert rows[0]["description"] == "A test user for validation" # 测试Reddit JSON格式 print("\n2. 测试Reddit Profile (JSON详细格式)") @@ -110,15 +112,21 @@ def test_profile_formats(): print(json.dumps(reddit_data[0], ensure_ascii=False, indent=4)) # 验证详细格式字段 - required_reddit_fields = ['realname', 'username', 'bio', 'persona'] - optional_reddit_fields = ['age', 'gender', 'mbti', 'country', 'profession', 'interested_topics'] + required_reddit_fields = [ + 'user_id', 'username', 'name', 'bio', 'persona', + 'karma', 'created_at', 'age', 'gender', 'mbti', 'country', + ] + optional_reddit_fields = ['profession', 'interested_topics'] missing = set(required_reddit_fields) - set(reddit_data[0].keys()) if missing: print(f"\n [错误] 缺少必需字段: {missing}") else: print(f"\n [通过] 所有必需字段都存在") - + assert reddit_data[0]["username"] == "test_user_123" + assert reddit_data[0]["country"] == "China" + assert reddit_data[1]["country"] == "China" + present_optional = set(optional_reddit_fields) & set(reddit_data[0].keys()) print(f" [信息] 可选字段: {present_optional}") @@ -135,19 +143,22 @@ def show_expected_formats(): print("\n1. Twitter Profile (CSV格式)") print("-" * 40) - twitter_example = """user_id,user_name,name,bio,friend_count,follower_count,statuses_count,created_at -0,user0,User Zero,I am user zero with interests in technology.,100,150,500,2023-01-01 -1,user1,User One,Tech enthusiast and coffee lover.,200,250,1000,2023-01-02""" + twitter_example = """user_id,name,username,user_char,description +0,User Zero,user0,I am user zero with interests in technology. Curious and analytical.,I am user zero with interests in technology. +1,User One,user1,Tech enthusiast and coffee lover. Loves sharing product opinions.,Tech enthusiast and coffee lover.""" print(twitter_example) print("\n2. Reddit Profile (JSON详细格式)") print("-" * 40) reddit_example = [ { - "realname": "James Miller", + "user_id": 0, "username": "millerhospitality", + "name": "James Miller", "bio": "Passionate about hospitality & tourism.", "persona": "James is a seasoned professional in the Hospitality & Tourism industry...", + "karma": 1000, + "created_at": "2023-01-01", "age": 40, "gender": "male", "mbti": "ESTJ", @@ -163,4 +174,3 @@ def show_expected_formats(): test_profile_formats() show_expected_formats() - diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 00000000..86a1a5ac --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,7 @@ +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) diff --git a/backend/tests/test_app_routes.py b/backend/tests/test_app_routes.py new file mode 100644 index 00000000..ce35475a --- /dev/null +++ b/backend/tests/test_app_routes.py @@ -0,0 +1,174 @@ +import sys +import types + +from flask import Blueprint + +import app as app_module +from app import create_app + + +def test_root_and_health_endpoints_expose_backend_status(monkeypatch): + api_module = types.ModuleType("app.api") + services_module = types.ModuleType("app.services") + simulation_runner_module = types.ModuleType("app.services.simulation_runner") + + api_module.graph_bp = Blueprint("graph", __name__) + api_module.simulation_bp = Blueprint("simulation", __name__) + api_module.report_bp = Blueprint("report", __name__) + + class FakeSimulationRunner: + @staticmethod + def register_cleanup(): + return None + + simulation_runner_module.SimulationRunner = FakeSimulationRunner + services_module.simulation_runner = simulation_runner_module + + monkeypatch.setitem(sys.modules, "app.api", api_module) + monkeypatch.setitem(sys.modules, "app.services", services_module) + monkeypatch.setitem(sys.modules, "app.services.simulation_runner", simulation_runner_module) + + app = create_app() + client = app.test_client() + + for path in ("/", "/health", "/healthz"): + response = client.get(path) + payload = response.get_json() + + assert response.status_code == 200 + assert payload["status"] == "ok" + assert payload["service"] == "MiroFish Backend" + assert payload["api_prefixes"] == [ + "/api/graph", + "/api/simulation", + "/api/report", + ] + assert payload["health_endpoint"] == "/health" + + +def test_api_cors_uses_env_backed_origin_list(monkeypatch): + api_module = types.ModuleType("app.api") + services_module = types.ModuleType("app.services") + simulation_runner_module = types.ModuleType("app.services.simulation_runner") + + graph_bp = Blueprint("graph", __name__) + api_module.graph_bp = graph_bp + api_module.simulation_bp = Blueprint("simulation", __name__) + api_module.report_bp = Blueprint("report", __name__) + + @graph_bp.route("/ping") + def ping(): + return {"ok": True} + + class FakeSimulationRunner: + @staticmethod + def register_cleanup(): + return None + + simulation_runner_module.SimulationRunner = FakeSimulationRunner + services_module.simulation_runner = simulation_runner_module + + monkeypatch.setitem(sys.modules, "app.api", api_module) + monkeypatch.setitem(sys.modules, "app.services", services_module) + monkeypatch.setitem(sys.modules, "app.services.simulation_runner", simulation_runner_module) + + class TestConfig: + DEBUG = False + JSON_AS_ASCII = False + + @staticmethod + def get_cors_resources(): + return { + "origins": ["https://app.example.test"], + "methods": ["GET", "POST"], + "allow_headers": ["Content-Type", "X-Locale"], + } + + app = create_app(config_class=TestConfig) + client = app.test_client() + + allowed = client.get("/api/graph/ping", headers={"Origin": "https://app.example.test"}) + blocked = client.get("/api/graph/ping", headers={"Origin": "https://other.example.test"}) + + assert allowed.status_code == 200 + assert allowed.headers["Access-Control-Allow-Origin"] == "https://app.example.test" + assert blocked.status_code == 200 + assert "Access-Control-Allow-Origin" not in blocked.headers + + +class _FakeLogger: + def __init__(self): + self.info_messages = [] + self.debug_messages = [] + + def info(self, message, *args, **kwargs): + self.info_messages.append(message) + + def debug(self, message, *args, **kwargs): + self.debug_messages.append(message) + + +def _install_fake_app_modules(monkeypatch): + api_module = types.ModuleType("app.api") + services_module = types.ModuleType("app.services") + simulation_runner_module = types.ModuleType("app.services.simulation_runner") + + graph_bp = Blueprint("graph", __name__) + api_module.graph_bp = graph_bp + api_module.simulation_bp = Blueprint("simulation", __name__) + api_module.report_bp = Blueprint("report", __name__) + + @graph_bp.route("/ping", methods=["POST"]) + def ping(): + return {"ok": True} + + class FakeSimulationRunner: + @staticmethod + def register_cleanup(): + return None + + simulation_runner_module.SimulationRunner = FakeSimulationRunner + services_module.simulation_runner = simulation_runner_module + + monkeypatch.setitem(sys.modules, "app.api", api_module) + monkeypatch.setitem(sys.modules, "app.services", services_module) + monkeypatch.setitem(sys.modules, "app.services.simulation_runner", simulation_runner_module) + + +def test_create_app_localizes_startup_logs_from_env(monkeypatch): + _install_fake_app_modules(monkeypatch) + startup_logger = _FakeLogger() + request_logger = _FakeLogger() + + monkeypatch.setenv("MIROFISH_LOCALE", "en") + monkeypatch.setattr(app_module, "setup_logger", lambda name="mirofish": startup_logger) + monkeypatch.setattr(app_module, "get_logger", lambda name="mirofish": request_logger) + + create_app() + + assert "MiroFish Backend is starting..." in startup_logger.info_messages + assert "Registered the simulation process cleanup hook" in startup_logger.info_messages + assert "MiroFish Backend startup completed" in startup_logger.info_messages + + +def test_request_logging_uses_request_locale(monkeypatch): + _install_fake_app_modules(monkeypatch) + startup_logger = _FakeLogger() + request_logger = _FakeLogger() + + monkeypatch.setattr(app_module, "setup_logger", lambda name="mirofish": startup_logger) + monkeypatch.setattr(app_module, "get_logger", lambda name="mirofish": request_logger) + + app = create_app() + client = app.test_client() + + response = client.post( + "/api/graph/ping", + headers={"X-Locale": "en", "Content-Type": "application/json"}, + json={"hello": "world"}, + ) + + assert response.status_code == 200 + assert "Request: POST /api/graph/ping" in request_logger.debug_messages + assert "Request body: {'hello': 'world'}" in request_logger.debug_messages + assert "Response: 200" in request_logger.debug_messages diff --git a/backend/tests/test_backend_localized_errors.py b/backend/tests/test_backend_localized_errors.py new file mode 100644 index 00000000..5393a583 --- /dev/null +++ b/backend/tests/test_backend_localized_errors.py @@ -0,0 +1,100 @@ +from flask import Flask + +from app.models.task import TaskManager +from app.utils.file_parser import FileParser +from app.utils.llm_client import LLMClient + + +def test_llm_client_missing_key_uses_english_request_locale(monkeypatch): + monkeypatch.setattr("app.utils.llm_client.Config.LLM_API_KEY", "") + + app = Flask(__name__) + with app.test_request_context(headers={"X-Locale": "en"}): + try: + LLMClient() + except ValueError as exc: + assert str(exc) == "LLM_API_KEY / OPENAI_API_KEY is not configured" + else: + raise AssertionError("expected ValueError when no API key is configured") + + +def test_llm_client_invalid_json_uses_english_request_locale(): + client = LLMClient.__new__(LLMClient) + client.chat = lambda *args, **kwargs: "not json" + + app = Flask(__name__) + with app.test_request_context(headers={"X-Locale": "en"}): + try: + client.chat_json([{"role": "user", "content": "hello"}]) + except ValueError as exc: + assert str(exc) == "The LLM returned invalid JSON: not json" + else: + raise AssertionError("expected ValueError for invalid JSON") + + +def test_file_parser_errors_use_english_request_locale(tmp_path): + app = Flask(__name__) + with app.test_request_context(headers={"X-Locale": "en"}): + missing_path = tmp_path / "missing.txt" + try: + FileParser.extract_text(str(missing_path)) + except FileNotFoundError as exc: + assert str(exc) == f"File not found: {missing_path}" + else: + raise AssertionError("expected FileNotFoundError for a missing file") + + unsupported_path = tmp_path / "sample.docx" + unsupported_path.write_text("hello", encoding="utf-8") + try: + FileParser.extract_text(str(unsupported_path)) + except ValueError as exc: + assert str(exc) == "Unsupported file format: .docx" + else: + raise AssertionError("expected ValueError for an unsupported file") + + +def test_file_parser_multi_document_wrappers_use_english_request_locale(tmp_path): + valid_path = tmp_path / "alpha.txt" + valid_path.write_text("hello", encoding="utf-8") + missing_path = tmp_path / "missing.txt" + + app = Flask(__name__) + with app.test_request_context(headers={"X-Locale": "en"}): + combined = FileParser.extract_from_multiple([str(valid_path), str(missing_path)]) + + assert "=== Document 1: alpha.txt ===\nhello" in combined + assert ( + f"=== Document 2: missing.txt (extraction failed: File not found: {missing_path}) ===" + in combined + ) + + +def test_file_parser_multi_document_wrappers_support_explicit_locale(tmp_path): + valid_path = tmp_path / "alpha.txt" + valid_path.write_text("hello", encoding="utf-8") + missing_path = tmp_path / "missing.txt" + + combined = FileParser.extract_from_multiple([str(valid_path), str(missing_path)], locale="en") + + assert "=== Document 1: alpha.txt ===\nhello" in combined + assert ( + f"=== Document 2: missing.txt (extraction failed: File not found: {missing_path}) ===" + in combined + ) + + +def test_task_manager_complete_and_fail_support_explicit_locale(): + manager = TaskManager() + manager._tasks.clear() + task_id = manager.create_task("demo") + + manager.complete_task(task_id, {"ok": True}, locale="en") + task = manager.get_task(task_id) + assert task is not None + assert task.message == "Task completed" + + manager.fail_task(task_id, "boom", locale="en") + task = manager.get_task(task_id) + assert task is not None + assert task.message == "Task failed" + assert task.error == "boom" diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py new file mode 100644 index 00000000..23c71a81 --- /dev/null +++ b/backend/tests/test_config.py @@ -0,0 +1,289 @@ +import importlib.util +import sys +from pathlib import Path + + +def load_config_module(): + config_path = Path(__file__).resolve().parents[1] / "app" / "config.py" + module_name = "test_config_module" + sys.modules.pop(module_name, None) + spec = importlib.util.spec_from_file_location(module_name, config_path) + assert spec is not None + assert spec.loader is not None + + config_module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(config_module) + return config_module + + +def test_config_accepts_openai_api_base_url_alias(monkeypatch): + monkeypatch.delenv("LLM_BASE_URL", raising=False) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + monkeypatch.setenv("OPENAI_API_BASE_URL", "https://codex.example.test/v1") + + config_module = load_config_module() + + assert config_module.Config.LLM_BASE_URL == "https://codex.example.test/v1" + + +def test_config_accepts_openai_base_url_alias(monkeypatch): + monkeypatch.delenv("LLM_BASE_URL", raising=False) + monkeypatch.delenv("OPENAI_API_BASE_URL", raising=False) + monkeypatch.setenv("OPENAI_BASE_URL", "https://api.openai.com/v1") + + config_module = load_config_module() + + assert config_module.Config.LLM_BASE_URL == "https://api.openai.com/v1" + + +def test_validate_returns_structured_errors_for_missing_keys(monkeypatch): + monkeypatch.delenv("LLM_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("ZEP_API_KEY", raising=False) + + config_module = load_config_module() + + errors = config_module.Config.validate() + + assert isinstance(errors, list) + assert "LLM_API_KEY / OPENAI_API_KEY 未配置" in errors + assert "ZEP_API_KEY 未配置" in errors + + +def test_validate_comprehensive_detects_invalid_url_and_numeric_values(monkeypatch): + monkeypatch.setenv("LLM_API_KEY", "test-key") + monkeypatch.setenv("ZEP_API_KEY", "zep-key") + monkeypatch.setenv("OPENAI_API_BASE_URL", "ftp://example.test") + monkeypatch.setenv("OASIS_DEFAULT_MAX_ROUNDS", "not-a-number") + monkeypatch.setenv("INTERVIEW_BATCH_TIMEOUT_SECONDS", "0") + monkeypatch.setenv("REPORT_AGENT_TEMPERATURE", "9") + + config_module = load_config_module() + result = config_module.Config.validate_comprehensive() + + assert result.is_valid is False + assert any("OPENAI_API_BASE_URL" in error for error in result.errors) + assert "OASIS_DEFAULT_MAX_ROUNDS 必须是合法数字,当前值: not-a-number" in result.errors + assert "INTERVIEW_BATCH_TIMEOUT_SECONDS 必须 >= 1,当前值: 0" in result.errors + assert "REPORT_AGENT_TEMPERATURE 必须 <= 2,当前值: 9" in result.errors + + +def test_validate_comprehensive_reports_debug_warning_and_safe_summary(monkeypatch): + monkeypatch.setenv("LLM_API_KEY", "test-key") + monkeypatch.setenv("ZEP_API_KEY", "zep-key") + monkeypatch.setenv("FLASK_DEBUG", "True") + monkeypatch.delenv("SECRET_KEY", raising=False) + + config_module = load_config_module() + result = config_module.Config.validate_comprehensive() + summary = config_module.Config.get_config_summary() + + assert any("DEBUG" in warning for warning in result.warnings) + assert any("SECRET_KEY" in warning for warning in result.warnings) + assert summary["llm"]["configured"] is True + assert summary["zep"]["configured"] is True + assert summary["cors"]["allowed_origins"] == [ + "http://localhost:3000", + "http://127.0.0.1:3000", + "http://localhost:4173", + "http://127.0.0.1:4173", + "http://localhost:5173", + "http://127.0.0.1:5173", + ] + assert summary["simulation"]["interview_timeouts"]["single_seconds"] == 120.0 + assert summary["security"]["secret_key_source"] == "generated" + assert "test-key" not in str(summary) + assert "zep-key" not in str(summary) + assert "mirofish-secret-key" not in config_module.Config.SECRET_KEY + assert config_module.validate_on_startup() is True + + +def test_config_defaults_to_debug_off_and_uses_explicit_secret_key(monkeypatch): + monkeypatch.setenv("SECRET_KEY", "configured-secret") + monkeypatch.delenv("FLASK_DEBUG", raising=False) + + config_module = load_config_module() + + assert config_module.Config.DEBUG is False + assert config_module.Config.SECRET_KEY == "configured-secret" + assert config_module.Config.SECRET_KEY_IS_GENERATED is False + assert config_module.Config.get_config_summary()["security"]["secret_key_source"] == "env" + + +def test_validate_comprehensive_can_render_english_messages(monkeypatch): + monkeypatch.delenv("LLM_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("ZEP_API_KEY", raising=False) + monkeypatch.setenv("OASIS_DEFAULT_MAX_ROUNDS", "bad-value") + + config_module = load_config_module() + result = config_module.Config.validate_comprehensive(locale="en") + + assert "LLM_API_KEY / OPENAI_API_KEY is not configured" in result.errors + assert "ZEP_API_KEY is not configured" in result.errors + assert "OASIS_DEFAULT_MAX_ROUNDS must be a valid number, current value: bad-value" in result.errors + + +def test_config_parses_cors_csv_environment_variables(monkeypatch): + monkeypatch.setenv( + "CORS_ALLOWED_ORIGINS", + "https://app.example.test, https://admin.example.test", + ) + monkeypatch.setenv("CORS_ALLOW_METHODS", "GET,POST") + monkeypatch.setenv("CORS_ALLOW_HEADERS", "Content-Type, X-Locale") + + config_module = load_config_module() + + assert config_module.Config.CORS_ALLOWED_ORIGINS == [ + "https://app.example.test", + "https://admin.example.test", + ] + assert config_module.Config.CORS_ALLOW_METHODS == ["GET", "POST"] + assert config_module.Config.CORS_ALLOW_HEADERS == ["Content-Type", "X-Locale"] + assert config_module.Config.get_cors_resources() == { + "origins": [ + "https://app.example.test", + "https://admin.example.test", + ], + "methods": ["GET", "POST"], + "allow_headers": ["Content-Type", "X-Locale"], + } + + +def test_config_defaults_cors_to_local_dev_origins(monkeypatch): + monkeypatch.delenv("CORS_ALLOWED_ORIGINS", raising=False) + + config_module = load_config_module() + + assert config_module.Config.CORS_ALLOWED_ORIGINS == [ + "http://localhost:3000", + "http://127.0.0.1:3000", + "http://localhost:4173", + "http://127.0.0.1:4173", + "http://localhost:5173", + "http://127.0.0.1:5173", + ] + assert "*" not in config_module.Config.CORS_ALLOWED_ORIGINS + + +def test_config_summary_reports_openai_compatible_alias_sources(monkeypatch): + monkeypatch.delenv("LLM_API_KEY", raising=False) + monkeypatch.delenv("LLM_BASE_URL", raising=False) + monkeypatch.delenv("LLM_MODEL_NAME", raising=False) + monkeypatch.setenv("OPENAI_API_KEY", "codex-key") + monkeypatch.setenv("OPENAI_API_BASE_URL", "https://codex.example.test/v1") + monkeypatch.setenv("OPENAI_MODEL", "gpt-4.1-mini") + monkeypatch.setenv("ZEP_API_KEY", "zep-key") + + config_module = load_config_module() + summary = config_module.Config.get_config_summary() + + assert summary["llm"]["backend_mode"] == "openai_compatible" + assert summary["llm"]["sources"] == { + "api_key_env": "OPENAI_API_KEY", + "base_url_env": "OPENAI_API_BASE_URL", + "model_env": "OPENAI_MODEL", + "base_url_conflict": None, + "uses_project_aliases": False, + "uses_openai_aliases": True, + } + assert summary["capabilities"] == { + "direct_llm": { + "ready": True, + }, + "graph_build": { + "ready": True, + "requires_zep": True, + }, + "graph_report_tools": { + "ready": True, + "requires_zep": True, + }, + "existing_simulation_interaction": { + "ready": True, + "requires_existing_simulation": True, + }, + } + + +def test_config_summary_reports_openai_base_url_source(monkeypatch): + monkeypatch.delenv("LLM_API_KEY", raising=False) + monkeypatch.delenv("LLM_BASE_URL", raising=False) + monkeypatch.delenv("LLM_MODEL_NAME", raising=False) + monkeypatch.delenv("OPENAI_API_BASE_URL", raising=False) + monkeypatch.setenv("OPENAI_API_KEY", "codex-key") + monkeypatch.setenv("OPENAI_BASE_URL", "https://api.openai.com/v1") + monkeypatch.setenv("OPENAI_MODEL", "gpt-4.1-mini") + monkeypatch.setenv("ZEP_API_KEY", "zep-key") + + config_module = load_config_module() + summary = config_module.Config.get_config_summary() + + assert summary["llm"]["backend_mode"] == "openai_compatible" + assert summary["llm"]["sources"] == { + "api_key_env": "OPENAI_API_KEY", + "base_url_env": "OPENAI_BASE_URL", + "model_env": "OPENAI_MODEL", + "base_url_conflict": None, + "uses_project_aliases": False, + "uses_openai_aliases": True, + } + + +def test_config_warns_when_openai_base_url_aliases_conflict(monkeypatch): + monkeypatch.delenv("LLM_BASE_URL", raising=False) + monkeypatch.setenv("OPENAI_API_KEY", "codex-key") + monkeypatch.setenv("OPENAI_BASE_URL", "https://api.openai.com/v1") + monkeypatch.setenv("OPENAI_API_BASE_URL", "https://codex-gateway.example.test/v1") + monkeypatch.setenv("OPENAI_MODEL", "gpt-4.1-mini") + monkeypatch.setenv("ZEP_API_KEY", "zep-key") + + config_module = load_config_module() + result = config_module.Config.validate_comprehensive(locale="en") + summary = config_module.Config.get_config_summary() + + assert result.is_valid is True + assert "OPENAI_BASE_URL / OPENAI_API_BASE_URL" in result.warnings[0] + assert "OPENAI_BASE_URL=https://api.openai.com/v1 will take precedence" in result.warnings[0] + assert summary["llm"]["base_url"] == "https://api.openai.com/v1" + assert summary["llm"]["sources"]["base_url_env"] == "OPENAI_BASE_URL" + assert summary["llm"]["sources"]["base_url_conflict"] == { + "has_conflict": True, + "selected_env": "OPENAI_BASE_URL", + "selected_value": "https://api.openai.com/v1", + "configured_envs": [ + {"name": "OPENAI_BASE_URL", "value": "https://api.openai.com/v1"}, + {"name": "OPENAI_API_BASE_URL", "value": "https://codex-gateway.example.test/v1"}, + ], + } + + +def test_config_summary_exposes_non_zep_capability_boundaries(monkeypatch): + monkeypatch.delenv("LLM_API_KEY", raising=False) + monkeypatch.delenv("LLM_BASE_URL", raising=False) + monkeypatch.delenv("LLM_MODEL_NAME", raising=False) + monkeypatch.setenv("OPENAI_API_KEY", "codex-key") + monkeypatch.setenv("OPENAI_API_BASE_URL", "https://codex.example.test/v1") + monkeypatch.setenv("OPENAI_MODEL", "gpt-4.1-mini") + monkeypatch.delenv("ZEP_API_KEY", raising=False) + + config_module = load_config_module() + summary = config_module.Config.get_config_summary() + + assert summary["capabilities"] == { + "direct_llm": { + "ready": True, + }, + "graph_build": { + "ready": False, + "requires_zep": True, + }, + "graph_report_tools": { + "ready": False, + "requires_zep": True, + }, + "existing_simulation_interaction": { + "ready": True, + "requires_existing_simulation": True, + }, + } diff --git a/backend/tests/test_error_handler.py b/backend/tests/test_error_handler.py new file mode 100644 index 00000000..135d9fac --- /dev/null +++ b/backend/tests/test_error_handler.py @@ -0,0 +1,66 @@ +from flask import Flask + +from app.config import Config +from app.utils.error_handler import error_response, handle_api_exception + + +class FakeLogger: + def __init__(self): + self.errors = [] + self.debugs = [] + + def error(self, message): + self.errors.append(message) + + def debug(self, message): + self.debugs.append(message) + + +def test_error_response_hides_traceback_when_debug_disabled(monkeypatch): + monkeypatch.setattr(Config, "DEBUG", False) + app = Flask(__name__) + + with app.app_context(): + try: + raise RuntimeError("boom") + except RuntimeError as error: + response, status_code = error_response("boom", original_error=error) + + assert status_code == 500 + assert response.get_json() == {"success": False, "error": "boom"} + + +def test_error_response_includes_traceback_when_debug_enabled(monkeypatch): + monkeypatch.setattr(Config, "DEBUG", True) + app = Flask(__name__) + + with app.app_context(): + try: + raise RuntimeError("boom") + except RuntimeError as error: + response, status_code = error_response("boom", original_error=error) + + payload = response.get_json() + + assert status_code == 500 + assert payload["success"] is False + assert payload["error"] == "boom" + assert "traceback" in payload + assert "RuntimeError: boom" in payload["traceback"] + + +def test_handle_api_exception_logs_and_returns_standard_payload(monkeypatch): + monkeypatch.setattr(Config, "DEBUG", False) + app = Flask(__name__) + logger = FakeLogger() + + with app.app_context(): + try: + raise ValueError("bad input") + except ValueError as error: + response, status_code = handle_api_exception(logger, error, "测试上下文") + + assert status_code == 500 + assert response.get_json() == {"success": False, "error": "bad input"} + assert logger.errors == ["测试上下文: bad input"] + assert logger.debugs diff --git a/backend/tests/test_graph_builder.py b/backend/tests/test_graph_builder.py new file mode 100644 index 00000000..92df723a --- /dev/null +++ b/backend/tests/test_graph_builder.py @@ -0,0 +1,592 @@ +import importlib +import itertools +import sys +from types import ModuleType, SimpleNamespace + +import pytest + + +@pytest.fixture +def graph_builder_module(monkeypatch): + zep_cloud = ModuleType("zep_cloud") + zep_cloud_client = ModuleType("zep_cloud.client") + zep_cloud_ontology = ModuleType("zep_cloud.external_clients.ontology") + + class FakeEpisodeData: + def __init__(self, data, type): + self.data = data + self.type = type + + class FakeEntityEdgeSourceTarget: + def __init__(self, source, target): + self.source = source + self.target = target + + class FakeInternalServerError(Exception): + pass + + class FakeZep: + def __init__(self, api_key): + self.api_key = api_key + + class FakeEntityModel: + pass + + class FakeEntityText: + pass + + class FakeEdgeModel: + pass + + zep_cloud.EpisodeData = FakeEpisodeData + zep_cloud.EntityEdgeSourceTarget = FakeEntityEdgeSourceTarget + zep_cloud.InternalServerError = FakeInternalServerError + zep_cloud_client.Zep = FakeZep + zep_cloud_ontology.EntityModel = FakeEntityModel + zep_cloud_ontology.EntityText = FakeEntityText + zep_cloud_ontology.EdgeModel = FakeEdgeModel + + monkeypatch.setitem(sys.modules, "zep_cloud", zep_cloud) + monkeypatch.setitem(sys.modules, "zep_cloud.client", zep_cloud_client) + monkeypatch.setitem(sys.modules, "zep_cloud.external_clients.ontology", zep_cloud_ontology) + monkeypatch.delitem(sys.modules, "app.services.graph_builder", raising=False) + + return importlib.import_module("app.services.graph_builder") + + +def build_service(graph_builder_module): + service = graph_builder_module.GraphBuilderService.__new__(graph_builder_module.GraphBuilderService) + service.client = SimpleNamespace(graph=SimpleNamespace()) + service.logger = SimpleNamespace(warning=lambda *args, **kwargs: None) + service.locale = "zh" + return service + + +def test_create_graph_retries_transient_zep_errors(graph_builder_module, monkeypatch): + service = build_service(graph_builder_module) + sleep_calls = [] + create_calls = [] + + def fake_create(**kwargs): + create_calls.append(kwargs) + if len(create_calls) < 3: + raise RuntimeError("429 Too Many Requests") + + service.client.graph.create = fake_create + monkeypatch.setattr(graph_builder_module.time, "sleep", sleep_calls.append) + + graph_id = service.create_graph("retry-test", max_retries=3) + + assert graph_id.startswith("mirofish_") + assert len(create_calls) == 3 + assert sleep_calls == [2.0, 4.0] + + +def test_create_graph_respects_retry_after_header(graph_builder_module, monkeypatch): + service = build_service(graph_builder_module) + sleep_calls = [] + create_calls = [] + + class FakeRateLimitError(RuntimeError): + status_code = 429 + + def __init__(self): + super().__init__("429 Too Many Requests") + self.headers = {"Retry-After": "7"} + + def fake_create(**kwargs): + create_calls.append(kwargs) + if len(create_calls) == 1: + raise FakeRateLimitError() + + service.client.graph.create = fake_create + monkeypatch.setattr(graph_builder_module.time, "sleep", sleep_calls.append) + + graph_id = service.create_graph("retry-after", max_retries=3) + + assert graph_id.startswith("mirofish_") + assert len(create_calls) == 2 + assert sleep_calls == [7.0] + + +def test_create_graph_respects_retry_after_text_hint(graph_builder_module, monkeypatch): + service = build_service(graph_builder_module) + sleep_calls = [] + create_calls = [] + + def fake_create(**kwargs): + create_calls.append(kwargs) + if len(create_calls) == 1: + raise RuntimeError("429 Too Many Requests; retry after 9 seconds") + + service.client.graph.create = fake_create + monkeypatch.setattr(graph_builder_module.time, "sleep", sleep_calls.append) + + graph_id = service.create_graph("retry-after-text", max_retries=3) + + assert graph_id.startswith("mirofish_") + assert len(create_calls) == 2 + assert sleep_calls == [9.0] + + +def test_create_graph_caps_retry_after_delay(graph_builder_module, monkeypatch): + service = build_service(graph_builder_module) + sleep_calls = [] + create_calls = [] + + class FakeRateLimitError(RuntimeError): + status_code = 429 + + def __init__(self): + super().__init__("429 Too Many Requests") + self.headers = {"Retry-After": "600"} + + def fake_create(**kwargs): + create_calls.append(kwargs) + if len(create_calls) == 1: + raise FakeRateLimitError() + + service.client.graph.create = fake_create + monkeypatch.setattr(graph_builder_module.time, "sleep", sleep_calls.append) + + graph_id = service.create_graph("retry-after-cap", max_retries=3) + + assert graph_id.startswith("mirofish_") + assert len(create_calls) == 2 + assert sleep_calls == [60.0] + + +def test_create_graph_does_not_retry_non_transient_errors(graph_builder_module, monkeypatch): + service = build_service(graph_builder_module) + sleep_calls = [] + create_calls = [] + + def fake_create(**kwargs): + create_calls.append(kwargs) + raise ValueError("invalid graph payload") + + service.client.graph.create = fake_create + monkeypatch.setattr(graph_builder_module.time, "sleep", sleep_calls.append) + + with pytest.raises(ValueError, match="invalid graph payload"): + service.create_graph("no-retry", max_retries=3) + + assert len(create_calls) == 1 + assert sleep_calls == [] + + +def test_add_text_batches_retries_failed_batch_once(graph_builder_module, monkeypatch): + service = build_service(graph_builder_module) + sleep_calls = [] + progress_updates = [] + add_batch_calls = [] + + def fake_add_batch(**kwargs): + add_batch_calls.append(kwargs) + if len(add_batch_calls) == 1: + raise RuntimeError("503 Service Unavailable") + return [SimpleNamespace(uuid_="episode-1"), SimpleNamespace(uuid="episode-2")] + + service.client.graph.add_batch = fake_add_batch + monkeypatch.setattr(graph_builder_module.time, "sleep", sleep_calls.append) + + episode_ids = service.add_text_batches( + "graph-1", + ["chunk-a", "chunk-b"], + batch_size=2, + progress_callback=lambda message, progress: progress_updates.append((message, progress)), + ) + + assert episode_ids == ["episode-1", "episode-2"] + assert len(add_batch_calls) == 2 + assert sleep_calls == [2.0, 1] + assert any("重试" in message for message, _ in progress_updates) + + +def test_add_text_batches_uses_english_progress_messages(graph_builder_module, monkeypatch): + service = build_service(graph_builder_module) + service.locale = "en" + progress_updates = [] + sleep_calls = [] + + service.client.graph.add_batch = lambda **kwargs: [SimpleNamespace(uuid_="episode-1")] + monkeypatch.setattr(graph_builder_module.time, "sleep", sleep_calls.append) + + episode_ids = service.add_text_batches( + "graph-1", + ["chunk-a", "chunk-b"], + batch_size=2, + progress_callback=lambda message, progress: progress_updates.append((message, progress)), + ) + + assert episode_ids == ["episode-1"] + assert sleep_calls == [1] + assert progress_updates == [("Sending batch 1/1 (2 chunk(s))...", 1.0)] + + +def test_wait_for_episodes_uses_english_progress_messages(graph_builder_module, monkeypatch): + service = build_service(graph_builder_module) + service.locale = "en" + progress_updates = [] + episode_state = {"episode-1": False} + time_values = itertools.repeat(100.0) + + def fake_get(*, uuid_): + processed = episode_state[uuid_] + episode_state[uuid_] = True + return SimpleNamespace(processed=processed) + + service.client.graph.episode = SimpleNamespace(get=fake_get) + monkeypatch.setattr(graph_builder_module.time, "sleep", lambda seconds: None) + monkeypatch.setattr(graph_builder_module.time, "time", lambda: next(time_values)) + + service._wait_for_episodes( + ["episode-1"], + progress_callback=lambda message, progress: progress_updates.append((message, progress)), + ) + + assert progress_updates == [ + ("Waiting for 1 text chunk(s) to finish processing...", 0), + ("Zep processing... 0/1 complete, 1 pending (0s)", 0.0), + ("Zep processing... 1/1 complete, 0 pending (0s)", 1.0), + ("Processing completed: 1/1", 1.0), + ] + + +def test_wait_for_episodes_without_entries_uses_english_progress_message(graph_builder_module): + service = build_service(graph_builder_module) + service.locale = "en" + progress_updates = [] + + service._wait_for_episodes( + [], + progress_callback=lambda message, progress: progress_updates.append((message, progress)), + ) + + assert progress_updates == [("No waiting required (no episodes)", 1.0)] + + +def test_build_graph_worker_uses_english_task_messages(graph_builder_module, monkeypatch): + service = build_service(graph_builder_module) + service.locale = "en" + updates = [] + completions = [] + + class FakeTaskManager: + def update_task(self, task_id, **kwargs): + updates.append((task_id, kwargs)) + + def complete_task(self, task_id, result, locale=None): + completions.append((task_id, result, locale)) + + def fail_task(self, task_id, error, locale=None): + raise AssertionError(f"worker unexpectedly failed: {error}") + + service.task_manager = FakeTaskManager() + service.create_graph = lambda graph_name: "graph-en-123" + service.set_ontology = lambda graph_id, ontology: None + service.add_text_batches = lambda graph_id, chunks, batch_size, progress_callback: ["episode-1", "episode-2"] + service._wait_for_episodes = lambda episode_uuids, progress_callback: None + service._get_graph_info = lambda graph_id: graph_builder_module.GraphInfo( + graph_id=graph_id, + node_count=3, + edge_count=2, + entity_types=["Person"], + ) + monkeypatch.setattr( + graph_builder_module.TextProcessor, + "split_text", + lambda text, chunk_size, chunk_overlap: ["chunk-1", "chunk-2", "chunk-3"], + ) + + service._build_graph_worker( + "task-en-1", + "example text", + {"entity_types": [], "edge_types": []}, + "English graph", + 500, + 50, + 2, + ) + + assert [item[1]["message"] for item in updates] == [ + "Starting graph build...", + "Graph created: graph-en-123", + "Ontology configured", + "Text split into 3 chunk(s)", + "Waiting for Zep to process the data...", + "Fetching graph info...", + ] + assert completions == [ + ( + "task-en-1", + { + "graph_id": "graph-en-123", + "graph_info": { + "graph_id": "graph-en-123", + "node_count": 3, + "edge_count": 2, + "entity_types": ["Person"], + }, + "chunks_processed": 3, + }, + "en", + ) + ] + + +def test_set_ontology_accepts_string_attribute_definitions(graph_builder_module): + service = build_service(graph_builder_module) + captured = {} + + def fake_set_ontology(**kwargs): + captured.update(kwargs) + + service.client.graph.set_ontology = fake_set_ontology + + service.set_ontology( + "graph-1", + { + "entity_types": [ + { + "name": "Person", + "description": "Person entity", + "attributes": ["full_name", {"name": "role", "description": "Role"}], + } + ], + "edge_types": [ + { + "name": "knows", + "description": "Knows edge", + "attributes": ["since", {"name": "context", "description": "Context"}], + "source_targets": [{"source": "Person", "target": "Person"}], + } + ], + }, + ) + + entities = captured["entities"] + edges = captured["edges"] + + assert "Person" in entities + assert entities["Person"].__annotations__["full_name"] is not None + assert entities["Person"].__annotations__["role"] is not None + + edge_model, source_targets = edges["KNOWS"] + assert edge_model.__name__ == "Knows" + assert edge_model.__annotations__["since"] is not None + assert edge_model.__annotations__["context"] is not None + assert len(source_targets) == 1 + assert source_targets[0].source == "Person" + assert source_targets[0].target == "Person" + + +def test_set_ontology_normalizes_entity_and_edge_type_names(graph_builder_module): + service = build_service(graph_builder_module) + captured = {} + + def fake_set_ontology(**kwargs): + captured.update(kwargs) + + service.client.graph.set_ontology = fake_set_ontology + + service.set_ontology( + "graph-1", + { + "entity_types": [ + { + "name": "university_student", + "description": "Student entity", + "attributes": [{"name": "major", "description": "Major"}], + }, + { + "name": "ResearchLab", + "description": "Lab entity", + "attributes": [{"name": "focus_area", "description": "Focus"}], + }, + ], + "edge_types": [ + { + "name": "WorksFor", + "description": "Employment edge", + "attributes": [{"name": "start_date", "description": "Start"}], + "source_targets": [ + {"source": "university_student", "target": "ResearchLab"} + ], + } + ], + }, + ) + + entities = captured["entities"] + edges = captured["edges"] + + assert "UniversityStudent" in entities + assert "ResearchLab" in entities + assert entities["UniversityStudent"].__annotations__["major"] is not None + assert entities["ResearchLab"].__annotations__["focus_area"] is not None + + edge_model, source_targets = edges["WORKS_FOR"] + assert edge_model.__name__ == "WorksFor" + assert edge_model.__annotations__["start_date"] is not None + assert len(source_targets) == 1 + assert source_targets[0].source == "UniversityStudent" + assert source_targets[0].target == "ResearchLab" + + +def test_get_graph_data_collapses_obvious_alias_duplicates_and_remaps_edges(graph_builder_module, monkeypatch): + service = build_service(graph_builder_module) + + nodes = [ + SimpleNamespace( + uuid_="node-short", + name="特朗普", + labels=["Entity", "人物"], + summary="Short summary", + attributes={"role": "candidate"}, + created_at="2026-01-01T00:00:00Z", + ), + SimpleNamespace( + uuid_="node-long", + name="美国总统特朗普", + labels=["Entity", "人物"], + summary="Longer summary with more context", + attributes={"title": "President"}, + created_at="2026-01-02T00:00:00Z", + ), + SimpleNamespace( + uuid_="node-other", + name="白宫", + labels=["Entity", "机构"], + summary="", + attributes={}, + created_at=None, + ), + ] + edges = [ + SimpleNamespace( + uuid_="edge-1", + name="VISITS", + fact="特朗普访问白宫", + source_node_uuid="node-short", + target_node_uuid="node-other", + attributes={}, + created_at=None, + valid_at=None, + invalid_at=None, + expired_at=None, + episodes=[], + ), + SimpleNamespace( + uuid_="edge-2", + name="VISITS", + fact="特朗普访问白宫", + source_node_uuid="node-long", + target_node_uuid="node-other", + attributes={}, + created_at=None, + valid_at=None, + invalid_at=None, + expired_at=None, + episodes=[], + ), + ] + + monkeypatch.setattr(graph_builder_module, "fetch_all_nodes", lambda client, graph_id: nodes) + monkeypatch.setattr(graph_builder_module, "fetch_all_edges", lambda client, graph_id: edges) + + result = service.get_graph_data("graph-1") + + assert result["node_count"] == 2 + assert result["edge_count"] == 1 + + merged_trump = next(node for node in result["nodes"] if node["uuid"] == "node-short") + assert merged_trump["name"] == "特朗普" + assert merged_trump["alias_names"] == ["特朗普", "美国总统特朗普"] + assert merged_trump["merged_node_uuids"] == ["node-short", "node-long"] + assert merged_trump["attributes"] == {"title": "President", "role": "candidate"} + assert merged_trump["summary"] == "Longer summary with more context" + + merged_edge = result["edges"][0] + assert merged_edge["source_node_uuid"] == "node-short" + assert merged_edge["source_node_name"] == "特朗普" + assert merged_edge["target_node_uuid"] == "node-other" + + +def test_get_graph_data_keeps_distinct_nodes_separate(graph_builder_module, monkeypatch): + service = build_service(graph_builder_module) + + nodes = [ + SimpleNamespace( + uuid_="node-1", + name="特朗普", + labels=["Entity", "人物"], + summary="", + attributes={}, + created_at=None, + ), + SimpleNamespace( + uuid_="node-2", + name="特朗普大厦", + labels=["Entity", "机构"], + summary="", + attributes={}, + created_at=None, + ), + ] + + monkeypatch.setattr(graph_builder_module, "fetch_all_nodes", lambda client, graph_id: nodes) + monkeypatch.setattr(graph_builder_module, "fetch_all_edges", lambda client, graph_id: []) + + result = service.get_graph_data("graph-2") + + assert result["node_count"] == 2 + assert sorted(node["uuid"] for node in result["nodes"]) == ["node-1", "node-2"] + + +def test_format_user_facing_error_maps_zep_auth_failures(graph_builder_module): + service = build_service(graph_builder_module) + + class FakeUnauthorizedError(Exception): + status_code = 401 + + error = FakeUnauthorizedError("401 unauthorized") + + assert "ZEP_API_KEY" in service.format_user_facing_error(error) + + +def test_format_user_facing_error_maps_embedded_traceback_auth_failures(graph_builder_module): + service = build_service(graph_builder_module) + + error = RuntimeError( + """Traceback (most recent call last): + File "/app/backend/.venv/lib/python3.11/site-packages/zep_cloud/graph/raw_client.py", line 713, in create + _response_json = _response.json() +json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0) +401 unauthorized +""" + ) + + assert "ZEP_API_KEY" in service.format_user_facing_error(error) + + +def test_format_user_facing_error_keeps_generic_message(graph_builder_module): + service = build_service(graph_builder_module) + + error = RuntimeError("invalid graph payload") + + assert service.format_user_facing_error(error) == "invalid graph payload" + + +def test_format_user_facing_error_strips_traceback_noise_from_generic_errors(graph_builder_module): + service = build_service(graph_builder_module) + + error = RuntimeError( + """Traceback (most recent call last): + File "/app/backend/app/services/graph_builder.py", line 42, in create_graph + raise RuntimeError("provider temporarily unavailable") +RuntimeError: provider temporarily unavailable +""" + ) + + assert service.format_user_facing_error(error) == "RuntimeError: provider temporarily unavailable" diff --git a/backend/tests/test_graph_upload_api.py b/backend/tests/test_graph_upload_api.py new file mode 100644 index 00000000..dd10c2e0 --- /dev/null +++ b/backend/tests/test_graph_upload_api.py @@ -0,0 +1,741 @@ +import importlib +import io +import sys +import types +from pathlib import Path + +from flask import Flask + + +class FakeValidationResult: + def __init__(self, errors=None): + self.errors = list(errors or []) + self.warnings = [] + self.info = [] + + @property + def is_valid(self): + return not self.errors + + def to_dict(self): + return { + "is_valid": self.is_valid, + "errors": self.errors, + "warnings": self.warnings, + "info": self.info, + "error_count": len(self.errors), + "warning_count": len(self.warnings), + } + + +class FakeLogger: + def __init__(self): + self.infos = [] + self.warnings = [] + self.errors = [] + self.debugs = [] + + def info(self, message): + self.infos.append(message) + + def warning(self, message): + self.warnings.append(message) + + def error(self, message): + self.errors.append(message) + + def debug(self, message): + self.debugs.append(message) + + +def load_graph_blueprint(monkeypatch): + for name in ( + "app.api", + "app.api.graph", + "app.api.simulation", + "app.api.report", + "app.services.graph_builder", + "app.services.ontology_generator", + ): + sys.modules.pop(name, None) + + simulation_stub = types.ModuleType("app.api.simulation") + report_stub = types.ModuleType("app.api.report") + graph_builder_stub = types.ModuleType("app.services.graph_builder") + ontology_stub = types.ModuleType("app.services.ontology_generator") + zep_cloud_stub = types.ModuleType("zep_cloud") + zep_cloud_client_stub = types.ModuleType("zep_cloud.client") + + class DummyGraphBuilderService: + pass + + class DummyOntologyGenerator: + def __init__(self, *args, **kwargs): + pass + + def generate(self, *args, **kwargs): + raise AssertionError("Ontology generation should not run for upload-validation failures") + + graph_builder_stub.GraphBuilderService = DummyGraphBuilderService + ontology_stub.OntologyGenerator = DummyOntologyGenerator + zep_cloud_client_stub.Zep = object + zep_cloud_stub.EpisodeData = object + zep_cloud_stub.EntityEdgeSourceTarget = object + zep_cloud_stub.InternalServerError = Exception + + monkeypatch.setitem(sys.modules, "app.api.simulation", simulation_stub) + monkeypatch.setitem(sys.modules, "app.api.report", report_stub) + monkeypatch.setitem(sys.modules, "app.services.graph_builder", graph_builder_stub) + monkeypatch.setitem(sys.modules, "app.services.ontology_generator", ontology_stub) + monkeypatch.setitem(sys.modules, "zep_cloud", zep_cloud_stub) + monkeypatch.setitem(sys.modules, "zep_cloud.client", zep_cloud_client_stub) + + api_module = importlib.import_module("app.api") + graph_module = importlib.import_module("app.api.graph") + return api_module.graph_bp, graph_module + + +def create_graph_test_client(monkeypatch, tmp_path): + graph_bp, graph_module = load_graph_blueprint(monkeypatch) + monkeypatch.setattr( + graph_module.ProjectManager, + "PROJECTS_DIR", + str(tmp_path / "projects"), + ) + monkeypatch.setattr( + graph_module.Config, + "validate_comprehensive", + lambda locale="zh": FakeValidationResult(), + ) + monkeypatch.setattr( + graph_module.Config, + "get_config_summary", + lambda: { + "llm": {"configured": True}, + "zep": {"configured": True}, + }, + ) + + app = Flask(__name__) + app.register_blueprint(graph_bp, url_prefix="/api/graph") + return app.test_client(), graph_module + + +def create_graph_build_test_client(monkeypatch, tmp_path): + for name in ( + "app.api", + "app.api.graph", + "app.api.simulation", + "app.api.report", + "app.services.graph_builder", + "app.services.ontology_generator", + ): + sys.modules.pop(name, None) + + simulation_stub = types.ModuleType("app.api.simulation") + report_stub = types.ModuleType("app.api.report") + ontology_stub = types.ModuleType("app.services.ontology_generator") + zep_cloud_stub = types.ModuleType("zep_cloud") + zep_cloud_client_stub = types.ModuleType("zep_cloud.client") + zep_cloud_ontology_stub = types.ModuleType("zep_cloud.external_clients.ontology") + + class DummyOntologyGenerator: + def __init__(self, *args, **kwargs): + pass + + class FakeZep: + def __init__(self, api_key): + self.api_key = api_key + + zep_cloud_client_stub.Zep = FakeZep + zep_cloud_stub.EpisodeData = object + zep_cloud_stub.EntityEdgeSourceTarget = object + zep_cloud_stub.InternalServerError = Exception + zep_cloud_ontology_stub.EntityModel = object + zep_cloud_ontology_stub.EntityText = object + zep_cloud_ontology_stub.EdgeModel = object + ontology_stub.OntologyGenerator = DummyOntologyGenerator + + monkeypatch.setitem(sys.modules, "app.api.simulation", simulation_stub) + monkeypatch.setitem(sys.modules, "app.api.report", report_stub) + monkeypatch.setitem(sys.modules, "app.services.ontology_generator", ontology_stub) + monkeypatch.setitem(sys.modules, "zep_cloud", zep_cloud_stub) + monkeypatch.setitem(sys.modules, "zep_cloud.client", zep_cloud_client_stub) + monkeypatch.setitem(sys.modules, "zep_cloud.external_clients.ontology", zep_cloud_ontology_stub) + + api_module = importlib.import_module("app.api") + graph_module = importlib.import_module("app.api.graph") + monkeypatch.setattr( + graph_module.ProjectManager, + "PROJECTS_DIR", + str(tmp_path / "projects"), + ) + monkeypatch.setattr( + graph_module.Config, + "validate_comprehensive", + lambda locale="zh": FakeValidationResult(), + ) + monkeypatch.setattr( + graph_module.Config, + "get_config_summary", + lambda: { + "llm": {"configured": True}, + "zep": {"configured": True}, + }, + ) + monkeypatch.setattr(graph_module.Config, "ZEP_API_KEY", "zep-test-key") + monkeypatch.setattr(graph_module.Config, "DEFAULT_CHUNK_SIZE", 500) + monkeypatch.setattr(graph_module.Config, "DEFAULT_CHUNK_OVERLAP", 50) + + app = Flask(__name__) + app.register_blueprint(api_module.graph_bp, url_prefix="/api/graph") + return app.test_client(), graph_module + + +def test_generate_ontology_returns_file_specific_parse_errors(monkeypatch, tmp_path): + client, graph_module = create_graph_test_client(monkeypatch, tmp_path) + + def raise_parse_error(_path): + raise ValueError("mock parse failure") + + monkeypatch.setattr(graph_module.FileParser, "extract_text", raise_parse_error) + + response = client.post( + "/api/graph/ontology/generate", + data={ + "simulation_requirement": "predict audience", + "files": (io.BytesIO(b"hello"), "sample.txt"), + }, + content_type="multipart/form-data", + ) + payload = response.get_json() + + assert response.status_code == 400 + assert payload["success"] is False + assert "1 个文档处理失败" in payload["error"] + assert payload["data"]["file_errors"] == [ + { + "filename": "sample.txt", + "code": "document_parse_failed", + "message": "文件 sample.txt 解析失败: mock parse failure", + "details": "mock parse failure", + } + ] + + projects_dir = Path(graph_module.ProjectManager.PROJECTS_DIR) + assert not any(projects_dir.iterdir()) if projects_dir.exists() else True + + +def test_generate_ontology_reports_unsupported_extensions_in_english(monkeypatch, tmp_path): + client, graph_module = create_graph_test_client(monkeypatch, tmp_path) + + response = client.post( + "/api/graph/ontology/generate", + headers={"X-Locale": "en"}, + data={ + "simulation_requirement": "predict audience", + "files": (io.BytesIO(b"hello"), "sample.docx"), + }, + content_type="multipart/form-data", + ) + payload = response.get_json() + + assert response.status_code == 400 + assert payload["success"] is False + assert payload["error"] == "1 document(s) could not be processed. Fix the reported file issues and retry." + assert payload["data"]["file_errors"] == [ + { + "filename": "sample.docx", + "code": "unsupported_file_type", + "message": "File sample.docx is not supported. Supported formats: markdown, md, pdf, txt", + "supported_extensions": ["markdown", "md", "pdf", "txt"], + } + ] + + +def test_generate_ontology_logs_are_localized_in_english(monkeypatch, tmp_path): + client, graph_module = create_graph_test_client(monkeypatch, tmp_path) + logger = FakeLogger() + monkeypatch.setattr(graph_module, "logger", logger) + + def raise_parse_error(_path): + raise ValueError("mock parse failure") + + monkeypatch.setattr(graph_module.FileParser, "extract_text", raise_parse_error) + + response = client.post( + "/api/graph/ontology/generate", + headers={"X-Locale": "en"}, + data={ + "project_name": "English ontology project", + "simulation_requirement": "predict audience reaction", + "files": (io.BytesIO(b"hello"), "sample.txt"), + }, + content_type="multipart/form-data", + ) + + assert response.status_code == 400 + assert logger.infos[0] == "=== Starting ontology generation ===" + assert "Project name: English ontology project" in logger.debugs + assert "Simulation requirement: predict audience reaction..." in logger.debugs + assert any(message.startswith("Created project: proj_") for message in logger.infos) + assert logger.warnings == ["Document parsing failed for sample.txt: mock parse failure"] + + +def test_get_graph_data_exception_logs_english_context(monkeypatch, tmp_path): + client, graph_module = create_graph_build_test_client(monkeypatch, tmp_path) + logger = FakeLogger() + monkeypatch.setattr(graph_module, "logger", logger) + + class ExplodingGraphBuilder: + def __init__(self, api_key): + assert api_key == "zep-test-key" + + def get_graph_data(self, graph_id): + assert graph_id == "graph_123" + raise RuntimeError("graph exploded") + + monkeypatch.setattr(graph_module, "GraphBuilderService", ExplodingGraphBuilder) + + response = client.get( + "/api/graph/data/graph_123", + headers={"X-Locale": "en"}, + ) + + assert response.status_code == 500 + assert response.get_json()["error"] == "graph exploded" + assert logger.errors == ["Failed to fetch graph data: graph exploded"] + assert logger.debugs + + +def test_generate_ontology_returns_backend_config_validation_when_env_is_missing(monkeypatch, tmp_path): + client, graph_module = create_graph_test_client(monkeypatch, tmp_path) + invalid_validation = FakeValidationResult( + errors=[ + "LLM_API_KEY / OPENAI_API_KEY is not configured", + "ZEP_API_KEY is not configured", + ] + ) + monkeypatch.setattr( + graph_module.Config, + "validate_comprehensive", + lambda locale="zh": invalid_validation, + ) + monkeypatch.setattr( + graph_module.Config, + "get_config_summary", + lambda: { + "llm": {"configured": False}, + "zep": {"configured": False}, + }, + ) + + response = client.post( + "/api/graph/ontology/generate", + headers={"X-Locale": "en"}, + data={ + "simulation_requirement": "predict audience", + "files": (io.BytesIO(b"hello"), "sample.txt"), + }, + content_type="multipart/form-data", + ) + payload = response.get_json() + + assert response.status_code == 503 + assert payload["success"] is False + assert "Backend configuration is incomplete:" in payload["error"] + assert payload["data"]["validation"]["is_valid"] is False + assert "LLM_API_KEY / OPENAI_API_KEY is not configured" in payload["data"]["validation"]["errors"] + assert "ZEP_API_KEY is not configured" in payload["data"]["validation"]["errors"] + assert payload["data"]["summary"]["llm"]["configured"] is False + assert payload["data"]["summary"]["zep"]["configured"] is False + + projects_dir = Path(graph_module.ProjectManager.PROJECTS_DIR) + assert not any(projects_dir.iterdir()) if projects_dir.exists() else True + + +def test_get_backend_config_status_reports_openai_compatible_alias_sources(monkeypatch, tmp_path): + client, graph_module = create_graph_test_client(monkeypatch, tmp_path) + validation = FakeValidationResult() + monkeypatch.setattr( + graph_module.Config, + "validate_comprehensive", + lambda locale="zh": validation, + ) + monkeypatch.setattr( + graph_module.Config, + "get_config_summary", + lambda: { + "llm": { + "backend_mode": "openai_compatible", + "configured": True, + "base_url": "https://api.openai.com/v1", + "model": "gpt-4.1-mini", + "sources": { + "api_key_env": "OPENAI_API_KEY", + "base_url_env": "OPENAI_API_BASE_URL", + "model_env": "OPENAI_MODEL", + "base_url_conflict": None, + "uses_project_aliases": False, + "uses_openai_aliases": True, + }, + }, + "zep": {"configured": True}, + }, + ) + + response = client.get("/api/graph/config/status", headers={"X-Locale": "en"}) + payload = response.get_json() + + assert response.status_code == 200 + assert payload["success"] is True + assert payload["data"]["validation"]["is_valid"] is True + assert payload["data"]["summary"]["llm"]["backend_mode"] == "openai_compatible" + assert payload["data"]["summary"]["llm"]["sources"] == { + "api_key_env": "OPENAI_API_KEY", + "base_url_env": "OPENAI_API_BASE_URL", + "model_env": "OPENAI_MODEL", + "base_url_conflict": None, + "uses_project_aliases": False, + "uses_openai_aliases": True, + } + + +def test_get_backend_config_status_reports_openai_base_url_source(monkeypatch, tmp_path): + client, graph_module = create_graph_test_client(monkeypatch, tmp_path) + validation = FakeValidationResult() + monkeypatch.setattr( + graph_module.Config, + "validate_comprehensive", + lambda locale="zh": validation, + ) + monkeypatch.setattr( + graph_module.Config, + "get_config_summary", + lambda: { + "llm": { + "backend_mode": "openai_compatible", + "configured": True, + "base_url": "https://api.openai.com/v1", + "model": "gpt-4.1-mini", + "sources": { + "api_key_env": "OPENAI_API_KEY", + "base_url_env": "OPENAI_BASE_URL", + "model_env": "OPENAI_MODEL", + "base_url_conflict": None, + "uses_project_aliases": False, + "uses_openai_aliases": True, + }, + }, + "zep": {"configured": True}, + }, + ) + + response = client.get("/api/graph/config/status", headers={"X-Locale": "en"}) + payload = response.get_json() + + assert response.status_code == 200 + assert payload["success"] is True + assert payload["data"]["summary"]["llm"]["sources"] == { + "api_key_env": "OPENAI_API_KEY", + "base_url_env": "OPENAI_BASE_URL", + "model_env": "OPENAI_MODEL", + "base_url_conflict": None, + "uses_project_aliases": False, + "uses_openai_aliases": True, + } + + +def test_build_graph_task_persists_sanitized_zep_auth_error(monkeypatch, tmp_path): + client, graph_module = create_graph_build_test_client(monkeypatch, tmp_path) + + graph_module.TaskManager()._tasks.clear() + + class ImmediateThread: + def __init__(self, target=None, args=(), kwargs=None, daemon=None): + self._target = target + self._args = args + self._kwargs = kwargs or {} + self.daemon = daemon + + def start(self): + self._target(*self._args, **self._kwargs) + + auth_traceback = """Traceback (most recent call last): + File "/app/backend/.venv/lib/python3.11/site-packages/zep_cloud/graph/raw_client.py", line 713, in create + _response_json = _response.json() +json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0) +401 unauthorized +""" + + monkeypatch.setattr(graph_module.threading, "Thread", ImmediateThread) + monkeypatch.setattr(graph_module.TextProcessor, "split_text", lambda text, chunk_size, overlap: ["chunk-1"]) + monkeypatch.setattr( + graph_module.GraphBuilderService, + "create_graph", + lambda self, name: (_ for _ in ()).throw(RuntimeError(auth_traceback)), + ) + + project = graph_module.ProjectManager.create_project("Auth failure test") + project.status = graph_module.ProjectStatus.ONTOLOGY_GENERATED + project.ontology = { + "entity_types": [{"name": "Person", "attributes": []}], + "edge_types": [], + } + graph_module.ProjectManager.save_project(project) + graph_module.ProjectManager.save_extracted_text(project.project_id, "test text") + + response = client.post( + "/api/graph/build", + json={"project_id": project.project_id, "graph_name": "Auth failure graph"}, + ) + payload = response.get_json() + + assert response.status_code == 200 + assert payload["success"] is True + + task_id = payload["data"]["task_id"] + task_response = client.get(f"/api/graph/task/{task_id}") + task_payload = task_response.get_json() + persisted_project = graph_module.ProjectManager.get_project(project.project_id) + + assert task_response.status_code == 200 + assert task_payload["data"]["status"] == "failed" + assert "ZEP_API_KEY" in task_payload["data"]["error"] + assert "Traceback" not in task_payload["data"]["error"] + assert "构建失败:" in task_payload["data"]["message"] + + assert persisted_project.status == graph_module.ProjectStatus.FAILED + assert "ZEP_API_KEY" in persisted_project.error + assert "Traceback" not in persisted_project.error + + +def test_build_graph_response_and_task_messages_are_localized_in_english(monkeypatch, tmp_path): + client, graph_module = create_graph_build_test_client(monkeypatch, tmp_path) + + graph_module.TaskManager()._tasks.clear() + + class ImmediateThread: + def __init__(self, target=None, args=(), kwargs=None, daemon=None): + self._target = target + self._args = args + self._kwargs = kwargs or {} + self.daemon = daemon + + def start(self): + self._target(*self._args, **self._kwargs) + + monkeypatch.setattr(graph_module.threading, "Thread", ImmediateThread) + monkeypatch.setattr(graph_module.TextProcessor, "split_text", lambda text, chunk_size, overlap: ["chunk-1", "chunk-2"]) + monkeypatch.setattr(graph_module.GraphBuilderService, "create_graph", lambda self, name: "graph_en_123") + monkeypatch.setattr(graph_module.GraphBuilderService, "set_ontology", lambda self, graph_id, ontology: None) + monkeypatch.setattr( + graph_module.GraphBuilderService, + "add_text_batches", + lambda self, graph_id, chunks, batch_size, progress_callback: ["episode-1", "episode-2"], + ) + monkeypatch.setattr( + graph_module.GraphBuilderService, + "_wait_for_episodes", + lambda self, episode_uuids, progress_callback: None, + ) + monkeypatch.setattr( + graph_module.GraphBuilderService, + "get_graph_data", + lambda self, graph_id: {"node_count": 3, "edge_count": 2}, + ) + + project = graph_module.ProjectManager.create_project("English graph build") + project.status = graph_module.ProjectStatus.ONTOLOGY_GENERATED + project.ontology = { + "entity_types": [{"name": "Person", "attributes": []}], + "edge_types": [], + } + graph_module.ProjectManager.save_project(project) + graph_module.ProjectManager.save_extracted_text(project.project_id, "test text") + + response = client.post( + "/api/graph/build", + headers={"X-Locale": "en"}, + json={"project_id": project.project_id, "graph_name": "English graph"}, + ) + payload = response.get_json() + + assert response.status_code == 200 + assert payload["success"] is True + assert payload["data"]["message"] == ( + f"The graph build task has started. Query /task/{payload['data']['task_id']} for progress." + ) + + task_response = client.get( + f"/api/graph/task/{payload['data']['task_id']}", + headers={"X-Locale": "en"}, + ) + task_payload = task_response.get_json() + + assert task_response.status_code == 200 + assert task_payload["data"]["status"] == "completed" + assert task_payload["data"]["task_type"] == "Build graph: English graph" + assert task_payload["data"]["message"] == "Graph build completed" + + +def test_failed_graph_task_message_is_localized_in_english(monkeypatch, tmp_path): + client, graph_module = create_graph_build_test_client(monkeypatch, tmp_path) + + graph_module.TaskManager()._tasks.clear() + + class ImmediateThread: + def __init__(self, target=None, args=(), kwargs=None, daemon=None): + self._target = target + self._args = args + self._kwargs = kwargs or {} + self.daemon = daemon + + def start(self): + self._target(*self._args, **self._kwargs) + + monkeypatch.setattr(graph_module.threading, "Thread", ImmediateThread) + monkeypatch.setattr(graph_module.TextProcessor, "split_text", lambda text, chunk_size, overlap: ["chunk-1"]) + monkeypatch.setattr( + graph_module.GraphBuilderService, + "create_graph", + lambda self, name: (_ for _ in ()).throw(RuntimeError("invalid graph payload")), + ) + + project = graph_module.ProjectManager.create_project("English auth failure test") + project.status = graph_module.ProjectStatus.ONTOLOGY_GENERATED + project.ontology = { + "entity_types": [{"name": "Person", "attributes": []}], + "edge_types": [], + } + graph_module.ProjectManager.save_project(project) + graph_module.ProjectManager.save_extracted_text(project.project_id, "test text") + + response = client.post( + "/api/graph/build", + headers={"X-Locale": "en"}, + json={"project_id": project.project_id, "graph_name": "English failure graph"}, + ) + payload = response.get_json() + + task_response = client.get( + f"/api/graph/task/{payload['data']['task_id']}", + headers={"X-Locale": "en"}, + ) + task_payload = task_response.get_json() + + assert task_response.status_code == 200 + assert task_payload["data"]["status"] == "failed" + assert task_payload["data"]["message"] == "Build failed: invalid graph payload" + assert task_payload["data"]["error"] == "invalid graph payload" + + +def test_graph_task_payload_translates_worker_progress_messages_in_english(monkeypatch, tmp_path): + _, graph_module = create_graph_build_test_client(monkeypatch, tmp_path) + + assert graph_module._translate_graph_task_type("en", "构建图谱: Example graph") == "Build graph: Example graph" + assert graph_module._translate_graph_task_type("zh", "Build graph: Example graph") == "构建图谱: Example graph" + assert graph_module._translate_graph_task_message("en", "开始构建图谱...") == "Starting graph build..." + assert graph_module._translate_graph_task_message("en", "图谱已创建: graph_en_123") == "Graph created: graph_en_123" + assert graph_module._translate_graph_task_message("en", "本体已设置") == "Ontology configured" + assert ( + graph_module._translate_graph_task_message("en", "文本已分割为 2 个块") + == "Text split into 2 chunk(s)" + ) + assert ( + graph_module._translate_graph_task_message("en", "发送第 1/3 批数据 (2 块)...") + == "Sending batch 1/3 (2 chunk(s))..." + ) + assert ( + graph_module._translate_graph_task_message("en", "批次 2 发送失败,4秒后重试 (1/3)...") + == "Batch 2 failed to send, retrying in 4s (1/3)..." + ) + assert ( + graph_module._translate_graph_task_message("en", "开始等待 5 个文本块处理...") + == "Waiting for 5 text chunk(s) to finish processing..." + ) + assert ( + graph_module._translate_graph_task_message("en", "部分文本块超时,已完成 3/5") + == "Some text chunks timed out, completed 3/5" + ) + assert ( + graph_module._translate_graph_task_message("en", "Zep处理中... 3/5 完成, 2 待处理 (9秒)") + == "Zep processing... 3/5 complete, 2 pending (9s)" + ) + assert ( + graph_module._translate_graph_task_message("en", "处理完成: 5/5") + == "Processing completed: 5/5" + ) + assert ( + graph_module._translate_graph_task_message("en", "无需等待(没有 episode)") + == "No waiting required (no episodes)" + ) + assert graph_module._translate_graph_task_message("en", "获取图谱信息...") == "Fetching graph info..." + + +def test_build_graph_logs_are_localized_in_english(monkeypatch, tmp_path): + client, graph_module = create_graph_build_test_client(monkeypatch, tmp_path) + api_logger = FakeLogger() + build_logger = FakeLogger() + monkeypatch.setattr(graph_module, "logger", api_logger) + monkeypatch.setattr(graph_module, "get_logger", lambda name: build_logger) + + graph_module.TaskManager()._tasks.clear() + + class ImmediateThread: + def __init__(self, target=None, args=(), kwargs=None, daemon=None): + self._target = target + self._args = args + self._kwargs = kwargs or {} + self.daemon = daemon + + def start(self): + self._target(*self._args, **self._kwargs) + + monkeypatch.setattr(graph_module.threading, "Thread", ImmediateThread) + monkeypatch.setattr(graph_module.TextProcessor, "split_text", lambda text, chunk_size, overlap: ["chunk-1"]) + monkeypatch.setattr(graph_module.GraphBuilderService, "create_graph", lambda self, name: "graph_en_123") + monkeypatch.setattr(graph_module.GraphBuilderService, "set_ontology", lambda self, graph_id, ontology: None) + monkeypatch.setattr( + graph_module.GraphBuilderService, + "add_text_batches", + lambda self, graph_id, chunks, batch_size, progress_callback: ["episode-1"], + ) + monkeypatch.setattr( + graph_module.GraphBuilderService, + "_wait_for_episodes", + lambda self, episode_uuids, progress_callback: None, + ) + monkeypatch.setattr( + graph_module.GraphBuilderService, + "get_graph_data", + lambda self, graph_id: {"node_count": 3, "edge_count": 2}, + ) + + project = graph_module.ProjectManager.create_project("English graph build") + project.status = graph_module.ProjectStatus.ONTOLOGY_GENERATED + project.ontology = { + "entity_types": [{"name": "Person", "attributes": []}], + "edge_types": [], + } + graph_module.ProjectManager.save_project(project) + graph_module.ProjectManager.save_extracted_text(project.project_id, "test text") + + response = client.post( + "/api/graph/build", + headers={"X-Locale": "en"}, + json={"project_id": project.project_id, "graph_name": "English graph"}, + ) + + assert response.status_code == 200 + assert api_logger.infos[0] == "=== Starting graph build ===" + assert api_logger.debugs == [f"Request params: project_id={project.project_id}"] + assert any( + message.startswith("Created graph build task: task_id=") and f"project_id={project.project_id}" in message + for message in api_logger.infos + ) + assert any("Starting graph build..." in message for message in build_logger.infos) + assert any( + "Graph build completed: graph_id=graph_en_123, nodes=3, edges=2" in message + for message in build_logger.infos + ) diff --git a/backend/tests/test_i18n.py b/backend/tests/test_i18n.py new file mode 100644 index 00000000..51a677b6 --- /dev/null +++ b/backend/tests/test_i18n.py @@ -0,0 +1,19 @@ +from flask import Flask + +from app.i18n import get_locale, tr + + +def test_get_locale_defaults_to_zh_without_request(): + assert get_locale() == "zh" + + +def test_get_locale_reads_request_header(): + app = Flask(__name__) + + with app.test_request_context(headers={"X-Locale": "en"}): + assert get_locale() == "en" + assert tr("graph.project_not_found", project_id="proj_123") == "Project not found: proj_123" + assert ( + tr("simulation.runner_dependency_error") + == "The optional OASIS simulation runtime dependencies are not installed. Run `npm run setup:backend:simulation`, or `uv sync --extra simulation` inside the backend directory first." + ) diff --git a/backend/tests/test_llm_client.py b/backend/tests/test_llm_client.py new file mode 100644 index 00000000..678c1a63 --- /dev/null +++ b/backend/tests/test_llm_client.py @@ -0,0 +1,211 @@ +from types import SimpleNamespace + +from app.utils.llm_client import LLMClient + + +def test_extract_json_payload_from_markdown_fence(): + raw = """```json +{"a": 1, "b": [2, 3]} +```""" + + assert LLMClient._extract_json_payload(raw) == '{"a": 1, "b": [2, 3]}' + + +def test_extract_json_payload_with_prefixed_reasoning_text(): + raw = """分析如下: +<think>先思考</think> +最终答案: +{"entity_types": [], "edge_types": []} +""" + + assert LLMClient._extract_json_payload(raw) == '{"entity_types": [], "edge_types": []}' + + +def test_extract_json_payload_with_prefixed_reasoning_text_and_array(): + raw = """Answer: +The best matching items are: +[ + {"name": "Alice"}, + {"name": "Bob"} +] +""" + + assert LLMClient._extract_json_payload(raw) == '[\n {"name": "Alice"},\n {"name": "Bob"}\n]' + + +def test_extract_json_payload_with_bom_and_markdown_array_fence(): + raw = "\ufeff```json\n[\n 1,\n 2,\n 3\n]\n```" + + assert LLMClient._extract_json_payload(raw) == '[\n 1,\n 2,\n 3\n]' + + +def test_chat_returns_empty_string_when_content_is_none(monkeypatch): + create_calls = [] + + class FakeCompletions: + def create(self, **kwargs): + create_calls.append(kwargs) + return SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace(content=None))] + ) + + class FakeOpenAI: + def __init__(self, api_key, base_url): + self.chat = SimpleNamespace(completions=FakeCompletions()) + + monkeypatch.setattr("app.utils.llm_client.OpenAI", FakeOpenAI) + + client = LLMClient(api_key="test-key", base_url="https://example.test/v1", model="test-model") + response = client.chat([{"role": "user", "content": "hello"}], response_format={"type": "json_object"}) + + assert response == "" + assert create_calls[0]["response_format"] == {"type": "json_object"} + + +def test_chat_json_omits_response_format_for_compatibility(monkeypatch): + create_calls = [] + + class FakeCompletions: + def create(self, **kwargs): + create_calls.append(kwargs) + return SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace(content='{"ok": true}'))] + ) + + class FakeOpenAI: + def __init__(self, api_key, base_url): + self.chat = SimpleNamespace(completions=FakeCompletions()) + + monkeypatch.setattr("app.utils.llm_client.OpenAI", FakeOpenAI) + + client = LLMClient(api_key="test-key", base_url="https://example.test/v1", model="test-model") + response = client.chat_json([{"role": "user", "content": "hello"}]) + + assert response == {"ok": True} + assert "response_format" not in create_calls[0] + + +def test_chat_retries_with_trimmed_messages_on_context_length_error(monkeypatch): + create_calls = [] + + class FakeBadRequestError(Exception): + pass + + class FakeCompletions: + def create(self, **kwargs): + create_calls.append(kwargs) + if len(create_calls) == 1: + raise FakeBadRequestError("context_length exceeded") + return SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace(content="trimmed response"))] + ) + + class FakeOpenAI: + def __init__(self, api_key, base_url): + self.chat = SimpleNamespace(completions=FakeCompletions()) + + monkeypatch.setattr("app.utils.llm_client.OpenAI", FakeOpenAI) + monkeypatch.setattr("app.utils.llm_client.BadRequestError", FakeBadRequestError) + + client = LLMClient(api_key="test-key", base_url="https://example.test/v1", model="test-model") + messages = [{"role": "system", "content": "system"}, {"role": "user", "content": "user"}] + messages.extend( + {"role": "assistant" if i % 2 == 0 else "user", "content": f"message-{i}"} + for i in range(12) + ) + + response = client.chat(messages) + + assert response == "trimmed response" + assert len(create_calls) == 2 + assert len(create_calls[1]["messages"]) < len(messages) + assert create_calls[1]["messages"][:2] == messages[:2] + + +def test_chat_retries_without_response_format_when_backend_rejects_json_mode(monkeypatch): + create_calls = [] + + class FakeBadRequestError(Exception): + pass + + class FakeCompletions: + def create(self, **kwargs): + create_calls.append(kwargs) + if "response_format" in kwargs: + raise FakeBadRequestError("invalid parameter: response_format") + return SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace(content='{"ok": true}'))] + ) + + class FakeOpenAI: + def __init__(self, api_key, base_url): + self.chat = SimpleNamespace(completions=FakeCompletions()) + + monkeypatch.setattr("app.utils.llm_client.OpenAI", FakeOpenAI) + monkeypatch.setattr("app.utils.llm_client.BadRequestError", FakeBadRequestError) + + client = LLMClient(api_key="test-key", base_url="https://example.test/v1", model="test-model") + response = client.chat( + [{"role": "user", "content": "hello"}], + response_format={"type": "json_object"}, + ) + + assert response == '{"ok": true}' + assert "response_format" in create_calls[0] + assert "response_format" not in create_calls[1] + + +def test_chat_retries_without_response_format_on_api_error(monkeypatch): + create_calls = [] + + class FakeAPIError(Exception): + pass + + class FakeCompletions: + def create(self, **kwargs): + create_calls.append(kwargs) + if "response_format" in kwargs: + raise FakeAPIError("response_format json_object unsupported") + return SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace(content="ok"))] + ) + + class FakeOpenAI: + def __init__(self, api_key, base_url): + self.chat = SimpleNamespace(completions=FakeCompletions()) + + monkeypatch.setattr("app.utils.llm_client.OpenAI", FakeOpenAI) + monkeypatch.setattr("app.utils.llm_client.APIError", FakeAPIError) + + client = LLMClient(api_key="test-key", base_url="https://example.test/v1", model="test-model") + response = client.chat( + [{"role": "user", "content": "hello"}], + response_format={"type": "json_object"}, + ) + + assert response == "ok" + assert "response_format" in create_calls[0] + assert "response_format" not in create_calls[1] + + +def test_chat_uses_configured_default_max_tokens(monkeypatch): + create_calls = [] + + class FakeCompletions: + def create(self, **kwargs): + create_calls.append(kwargs) + return SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace(content="ok"))] + ) + + class FakeOpenAI: + def __init__(self, api_key, base_url): + self.chat = SimpleNamespace(completions=FakeCompletions()) + + monkeypatch.setattr("app.utils.llm_client.OpenAI", FakeOpenAI) + monkeypatch.setattr("app.utils.llm_client.Config.LLM_MAX_TOKENS", 1234) + + client = LLMClient(api_key="test-key", base_url="https://example.test/v1", model="test-model") + client.chat([{"role": "user", "content": "hello"}]) + + assert create_calls[0]["max_tokens"] == 1234 diff --git a/backend/tests/test_llm_env.py b/backend/tests/test_llm_env.py new file mode 100644 index 00000000..0cea5a4c --- /dev/null +++ b/backend/tests/test_llm_env.py @@ -0,0 +1,242 @@ +import importlib.util +import sys +from pathlib import Path + + +def load_llm_env_module(): + module_path = Path(__file__).resolve().parents[1] / "scripts" / "llm_env.py" + module_name = "test_llm_env_module" + sys.modules.pop(module_name, None) + spec = importlib.util.spec_from_file_location(module_name, module_path) + assert spec is not None + assert spec.loader is not None + + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_resolve_standard_llm_env_accepts_openai_api_base_url(monkeypatch): + monkeypatch.delenv("LLM_API_KEY", raising=False) + monkeypatch.delenv("LLM_BASE_URL", raising=False) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + monkeypatch.setenv("OPENAI_API_KEY", "codex-key") + monkeypatch.setenv("OPENAI_API_BASE_URL", "https://codex.example.test/v1") + monkeypatch.setenv("OPENAI_MODEL", "gpt-4.1-mini") + + llm_env = load_llm_env_module() + + assert llm_env.resolve_standard_llm_env() == ( + "codex-key", + "https://codex.example.test/v1", + "gpt-4.1-mini", + ) + + +def test_resolve_standard_model_name_accepts_openai_model(monkeypatch): + monkeypatch.delenv("LLM_MODEL_NAME", raising=False) + monkeypatch.setenv("OPENAI_MODEL", "gpt-5-mini") + + llm_env = load_llm_env_module() + + assert llm_env.resolve_standard_model_name() == "gpt-5-mini" + + +def test_apply_openai_compat_env_sets_expected_aliases(monkeypatch): + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + monkeypatch.delenv("OPENAI_API_BASE_URL", raising=False) + monkeypatch.delenv("OPENAI_MODEL", raising=False) + + llm_env = load_llm_env_module() + llm_env.apply_openai_compat_env( + "test-key", + "https://gateway.example.test/v1", + "gpt-4.1-mini", + ) + + assert llm_env.os.environ["OPENAI_API_KEY"] == "test-key" + assert llm_env.os.environ["OPENAI_BASE_URL"] == "https://gateway.example.test/v1" + assert llm_env.os.environ["OPENAI_API_BASE_URL"] == "https://gateway.example.test/v1" + assert llm_env.os.environ["OPENAI_MODEL"] == "gpt-4.1-mini" + + +def test_apply_openai_compat_env_clears_stale_aliases(monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "stale-key") + monkeypatch.setenv("OPENAI_BASE_URL", "https://stale.example.test/v1") + monkeypatch.setenv("OPENAI_API_BASE_URL", "https://stale.example.test/v1") + monkeypatch.setenv("OPENAI_MODEL", "stale-model") + + llm_env = load_llm_env_module() + llm_env.apply_openai_compat_env("", "", "") + + assert "OPENAI_API_KEY" not in llm_env.os.environ + assert "OPENAI_BASE_URL" not in llm_env.os.environ + assert "OPENAI_API_BASE_URL" not in llm_env.os.environ + assert "OPENAI_MODEL" not in llm_env.os.environ + + +def test_missing_api_key_message_mentions_openai_alias(monkeypatch): + llm_env = load_llm_env_module() + + assert llm_env.missing_api_key_message() == ( + "缺少 API Key 配置,请在项目根目录 .env 文件中设置 LLM_API_KEY 或 OPENAI_API_KEY" + ) + + +def test_missing_api_key_message_supports_english(monkeypatch): + llm_env = load_llm_env_module() + + assert llm_env.missing_api_key_message("en") == ( + "Missing API key configuration. Set LLM_API_KEY or OPENAI_API_KEY " + "in the project root .env file." + ) + + +def test_script_message_supports_english_runtime_strings(): + llm_env = load_llm_env_module() + + assert llm_env.script_message("missing_dependency", "en", dependency="camel") == ( + "Error: missing dependency camel" + ) + assert llm_env.script_message("profile_missing", "en", path="/tmp/profile.json") == ( + "Error: profile file does not exist: /tmp/profile.json" + ) + assert llm_env.script_message("install_simulation_deps_npm", "en") == ( + "Install the optional simulation dependencies first: `npm run setup:backend:simulation`" + ) + assert llm_env.script_message("default_llm_label", "en") == "[default LLM]" + assert llm_env.script_message("boost_llm_label", "en") == "[boost LLM]" + assert llm_env.script_message("default_base_url", "en") == "default" + assert llm_env.script_message("batch_interview_completed", "en", count=3) == ( + " Batch interview completed: 3 agents" + ) + assert llm_env.script_message("close_command_ack", "en") == "The environment is shutting down" + assert llm_env.script_message("unknown_command", "en", command_type="mystery") == ( + "Unknown command type: mystery" + ) + assert llm_env.script_message("no_valid_agents", "en") == "No valid agents were found" + assert llm_env.script_message("no_successful_interviews", "en") == ( + "No interviews completed successfully" + ) + assert llm_env.script_message("platform_unavailable", "en", platform="twitter") == ( + "twitter platform is unavailable" + ) + assert llm_env.script_message("no_available_simulation_env", "en") == ( + "No simulation environment is available" + ) + assert llm_env.script_message( + "platform_agent_lookup_warning", + "en", + platform="Twitter", + agent_id=7, + error="boom", + ) == " Warning: failed to load Twitter agent 7: boom" + assert llm_env.script_message("runner_title", "en", platform="Twitter") == ( + "OASIS Twitter simulation" + ) + assert llm_env.script_message("runner_title", "en", platform="dual-platform parallel") == ( + "OASIS dual-platform parallel simulation" + ) + assert llm_env.script_message("cli_config_help", "en") == ( + "Path to the configuration file (simulation_config.json)" + ) + assert llm_env.script_message("cli_max_rounds_help", "en") == ( + "Maximum simulation rounds (optional, truncates long simulations)" + ) + assert llm_env.script_message("cli_no_wait_help", "en") == ( + "Close the environment after the simulation and skip wait-for-command mode" + ) + assert llm_env.script_message("config_path", "en", path="/tmp/config.json") == ( + "Config file: /tmp/config.json" + ) + assert llm_env.script_message("simulation_params", "en") == "\nSimulation parameters:" + assert llm_env.script_message("effective_rounds", "en", rounds=12) == ( + " - Effective rounds: 12 (truncated)" + ) + assert llm_env.script_message( + "round_progress", + "en", + day=2, + hour=9, + round=10, + total_rounds=48, + progress=20.8, + agent_count=7, + elapsed=15.2, + ) == " [Day 2, 09:00] Round 10/48 (20.8%) - 7 agents active - elapsed: 15.2s" + assert llm_env.script_message("env_closed", "en") == "Environment closed" + assert llm_env.script_message("signal_received", "en", signal_name="SIGTERM") == ( + "\nReceived SIGTERM; shutting down..." + ) + assert llm_env.script_message("shutdown_round_stop", "en", round=7) == ( + "Received shutdown signal; stopping simulation at round 7" + ) + assert llm_env.script_message( + "simulation_loop_summary", + "en", + elapsed=15.2, + total_actions=42, + ) == "Simulation loop complete! Elapsed: 15.2s, total actions: 42" + assert llm_env.script_message("llm_config", "en", model="gpt-4.1-mini", base_url="default") == ( + "LLM config: model=gpt-4.1-mini, base_url=default..." + ) + assert llm_env.script_message( + "llm_config_with_label", + "en", + label="[boost LLM]", + model="gpt-4.1-mini", + base_url="default", + ) == "[boost LLM] model=gpt-4.1-mini, base_url=default..." + + +def test_script_message_defaults_to_chinese_runtime_strings(): + llm_env = load_llm_env_module() + + assert llm_env.script_message("missing_dependency", dependency="camel") == "错误: 缺少依赖 camel" + assert llm_env.script_message("config_missing", path="/tmp/config.json") == ( + "错误: 配置文件不存在: /tmp/config.json" + ) + assert llm_env.script_message("unknown_error") == "未知错误" + assert llm_env.script_message("close_command_ack") == "环境即将关闭" + assert llm_env.script_message("unknown_command", command_type="mystery") == ( + "未知命令类型: mystery" + ) + assert llm_env.script_message("platform_unavailable", platform="twitter") == "twitter平台不可用" + assert llm_env.script_message("no_available_simulation_env") == "没有可用的模拟环境" + assert llm_env.script_message( + "platform_agent_lookup_warning", + platform="Twitter", + agent_id=7, + error="boom", + ) == " 警告: 无法获取Twitter Agent 7: boom" + assert llm_env.script_message("install_simulation_deps_uv") == ( + "或在 backend 目录执行: `uv sync --extra simulation`" + ) + assert llm_env.script_message("default_llm_label") == "[通用LLM]" + assert llm_env.script_message("boost_llm_label") == "[加速LLM]" + assert llm_env.script_message("default_base_url") == "默认" + assert llm_env.script_message("runner_title", platform="双平台并行") == "OASIS 双平台并行模拟" + assert llm_env.script_message("cli_config_help") == "配置文件路径 (simulation_config.json)" + assert llm_env.script_message("cli_max_rounds_help") == "最大模拟轮数(可选,用于截断过长的模拟)" + assert llm_env.script_message("cli_no_wait_help") == "模拟完成后立即关闭环境,不进入等待命令模式" + assert llm_env.script_message("config_path", path="/tmp/config.json") == "配置文件: /tmp/config.json" + assert llm_env.script_message("supported_commands") == "支持的命令: interview, batch_interview, close_env" + assert llm_env.script_message("wait_mode", state="启用") == "等待命令模式: 启用" + assert llm_env.script_message("effective_rounds", rounds=12) == " - 实际执行轮数: 12 (已截断)" + assert llm_env.script_message( + "round_progress", + day=2, + hour=9, + round=10, + total_rounds=48, + progress=20.8, + agent_count=7, + elapsed=15.2, + ) == " [第2天, 09:00] 第 10/48 轮 (20.8%) - 活跃 Agent 7 个 - 已耗时: 15.2秒" + assert llm_env.script_message("shutdown_round_stop", round=7) == "收到退出信号,在第 7 轮停止模拟" + assert llm_env.script_message( + "simulation_loop_summary", + elapsed=15.2, + total_actions=42, + ) == "模拟循环完成! 耗时: 15.2秒, 总动作: 42" diff --git a/backend/tests/test_ontology_generator.py b/backend/tests/test_ontology_generator.py new file mode 100644 index 00000000..dd8b4c48 --- /dev/null +++ b/backend/tests/test_ontology_generator.py @@ -0,0 +1,183 @@ +import importlib.util +import sys +import types +from pathlib import Path + + +def load_ontology_generator(): + backend_root = Path(__file__).resolve().parents[1] + app_root = backend_root / "app" + services_root = app_root / "services" + module_path = services_root / "ontology_generator.py" + + app_module = sys.modules.setdefault("app", types.ModuleType("app")) + app_module.__path__ = [str(app_root)] + + services_module = sys.modules.setdefault("app.services", types.ModuleType("app.services")) + services_module.__path__ = [str(services_root)] + app_module.services = services_module + + spec = importlib.util.spec_from_file_location("app.services.ontology_generator", module_path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +OntologyGenerator = load_ontology_generator().OntologyGenerator + + +class FakeLLM: + def __init__(self): + self.messages = None + + def chat_json(self, messages, temperature, max_tokens): + self.messages = messages + return { + "entity_types": [ + {"name": "Player", "description": "Game player", "attributes": [], "examples": []}, + {"name": "Studio", "description": "Game studio", "attributes": [], "examples": []}, + {"name": "Analyst", "description": "Market analyst", "attributes": [], "examples": []}, + {"name": "Streamer", "description": "Content creator", "attributes": [], "examples": []}, + {"name": "Journalist", "description": "Reporter", "attributes": [], "examples": []}, + {"name": "Publisher", "description": "Publisher", "attributes": [], "examples": []}, + {"name": "Community", "description": "Fan community", "attributes": [], "examples": []}, + {"name": "Platform", "description": "Distribution platform", "attributes": [], "examples": []}, + {"name": "Person", "description": "Fallback person", "attributes": [], "examples": []}, + {"name": "Organization", "description": "Fallback org", "attributes": [], "examples": []}, + ], + "edge_types": [], + "analysis_summary": "English summary", + } + + +def test_validate_and_process_normalizes_string_and_invalid_ontology_items(): + generator = OntologyGenerator(llm_client=object()) + + result = generator._validate_and_process( + { + "entity_types": [ + "PersonLike", + { + "name": "Analyst", + "description": "x" * 120, + }, + 123, + ], + "edge_types": [ + "RELATES_TO", + { + "name": "MENTIONS", + "description": "y" * 120, + }, + None, + ], + } + ) + + assert result["entity_types"][0] == { + "name": "PersonLike", + "description": "Entity type: PersonLike", + "attributes": [], + "examples": [], + } + assert result["entity_types"][1]["name"] == "Analyst" + assert result["entity_types"][1]["attributes"] == [] + assert result["entity_types"][1]["examples"] == [] + assert result["entity_types"][1]["description"].endswith("...") + assert len(result["entity_types"][1]["description"]) == 100 + assert {entity["name"] for entity in result["entity_types"]} >= { + "Person", + "Organization", + } + + assert result["edge_types"] == [ + { + "name": "RELATES_TO", + "description": "Relationship type: RELATES_TO", + "source_targets": [], + "attributes": [], + }, + { + "name": "MENTIONS", + "description": ("y" * 97) + "...", + "source_targets": [], + "attributes": [], + }, + ] + + +def test_validate_and_process_normalizes_zep_schema_names(): + generator = OntologyGenerator(llm_client=object()) + + result = generator._validate_and_process( + { + "entity_types": [ + {"name": "university_student", "description": "Student actor"}, + {"name": "govAgency", "description": "Agency actor"}, + {"name": "person", "description": "fallback"}, + {"name": "organization", "description": "fallback"}, + ], + "edge_types": [ + { + "name": "worksFor", + "description": "Employment relationship", + "source_targets": [ + {"source": "university_student", "target": "govAgency"}, + ], + } + ], + } + ) + + entity_names = {entity["name"] for entity in result["entity_types"]} + assert "UniversityStudent" in entity_names + assert "GovAgency" in entity_names + assert "Person" in entity_names + assert "Organization" in entity_names + assert result["edge_types"] == [ + { + "name": "WORKS_FOR", + "description": "Employment relationship", + "source_targets": [ + {"source": "UniversityStudent", "target": "GovAgency"}, + ], + "attributes": [], + } + ] + + +def test_generate_requests_english_analysis_summary_when_locale_is_en(): + llm = FakeLLM() + generator = OntologyGenerator(llm_client=llm, locale="en") + + result = generator.generate( + document_texts=["Game design notes"], + simulation_requirement="Predict the target audience for this game", + ) + + assert result["analysis_summary"] == "English summary" + assert llm.messages is not None + assert "Brief analysis summary of the text content (English)" in llm.messages[0]["content"] + assert "social-media public-opinion simulation" in llm.messages[0]["content"] + assert "## Simulation Requirement" in llm.messages[1]["content"] + assert "## Source Documents" in llm.messages[1]["content"] + assert "Keep all descriptions and `analysis_summary` in English" in llm.messages[1]["content"] + + +def test_build_user_message_uses_english_sections_and_truncation_notice(): + generator = OntologyGenerator(llm_client=object(), locale="en") + generator.MAX_TEXT_LENGTH_FOR_LLM = 10 + + message = generator._build_user_message( + document_texts=["abcdefghijklmno"], + simulation_requirement="Predict market reaction", + additional_context="Focus on launch-week discussion.", + ) + + assert "## Simulation Requirement" in message + assert "## Source Documents" in message + assert "## Additional Context" in message + assert "original text length: 15 characters" in message + assert "Keep all descriptions and `analysis_summary` in English" in message diff --git a/backend/tests/test_openai_compat_services.py b/backend/tests/test_openai_compat_services.py new file mode 100644 index 00000000..be07947f --- /dev/null +++ b/backend/tests/test_openai_compat_services.py @@ -0,0 +1,832 @@ +import sys +import json +from types import SimpleNamespace +from types import ModuleType + +fake_zep_cloud = ModuleType("zep_cloud") +fake_zep_client = ModuleType("zep_cloud.client") +fake_zep_client.Zep = object +fake_zep_cloud.client = fake_zep_client +fake_zep_cloud.__getattr__ = lambda name: object +sys.modules.setdefault("zep_cloud", fake_zep_cloud) +sys.modules.setdefault("zep_cloud.client", fake_zep_client) + +from app.services.oasis_profile_generator import OasisAgentProfile, OasisProfileGenerator +from app.services.graph_builder import GraphBuilderService +from app.services import simulation_config_generator as simulation_config_generator_module +from app.services.simulation_config_generator import SimulationConfigGenerator +from app.services.simulation_config_generator import EventConfig +from app.services.zep_entity_reader import ZepEntityReader +from app.services.zep_graph_memory_updater import ( + AgentActivity, + ZepGraphMemoryManager, + ZepGraphMemoryUpdater, +) +from app.services.zep_tools import ZepToolsService + + +def _make_response(content, finish_reason="stop"): + return SimpleNamespace( + choices=[ + SimpleNamespace( + message=SimpleNamespace(content=content), + finish_reason=finish_reason, + ) + ] + ) + + +def test_oasis_profile_generator_retries_without_response_format_on_unsupported_json_mode(): + create_calls = [] + warning_messages = [] + + class FakeCompletions: + def create(self, **kwargs): + create_calls.append(kwargs) + if "response_format" in kwargs: + raise RuntimeError("response_format json_object unsupported") + return _make_response('Answer:\n{"bio":"Test bio","persona":"Test persona"}') + + generator = OasisProfileGenerator.__new__(OasisProfileGenerator) + generator.model_name = "test-model" + generator.locale = "en" + generator.client = SimpleNamespace(chat=SimpleNamespace(completions=FakeCompletions())) + original_logger = sys.modules["app.services.oasis_profile_generator"].logger + sys.modules["app.services.oasis_profile_generator"].logger = SimpleNamespace(warning=warning_messages.append) + + try: + result = generator._request_json_completion( + messages=[{"role": "user", "content": "hello"}], + temperature=0.5, + ) + finally: + sys.modules["app.services.oasis_profile_generator"].logger = original_logger + + assert result["content"] == '{"bio":"Test bio","persona":"Test persona"}' + assert "response_format" in create_calls[0] + assert "response_format" not in create_calls[1] + assert warning_messages == ["LLM backend rejected response_format=json_object; retrying without JSON mode"] + + +def test_simulation_config_generator_retries_without_response_format_on_unsupported_json_mode(): + create_calls = [] + warning_messages = [] + + class FakeCompletions: + def create(self, **kwargs): + create_calls.append(kwargs) + if "response_format" in kwargs: + raise RuntimeError("invalid parameter: response_format") + return _make_response('```json\n{"time_config": {"total_simulation_hours": 12}}\n```') + + generator = SimulationConfigGenerator.__new__(SimulationConfigGenerator) + generator.model_name = "test-model" + generator.locale = "zh" + generator.client = SimpleNamespace(chat=SimpleNamespace(completions=FakeCompletions())) + original_logger = simulation_config_generator_module.logger + simulation_config_generator_module.logger = SimpleNamespace(warning=warning_messages.append) + + try: + result = generator._request_json_completion( + messages=[{"role": "user", "content": "hello"}], + temperature=0.6, + ) + finally: + simulation_config_generator_module.logger = original_logger + + assert result["content"] == '{"time_config": {"total_simulation_hours": 12}}' + assert "response_format" in create_calls[0] + assert "response_format" not in create_calls[1] + assert warning_messages == ["LLM 后端不支持 response_format=json_object;将改为不使用 JSON 模式重试"] + + +def test_oasis_profile_generator_missing_api_key_mentions_openai_alias(monkeypatch): + monkeypatch.setattr("app.services.oasis_profile_generator.Config.LLM_API_KEY", "") + + try: + OasisProfileGenerator() + except ValueError as exc: + assert str(exc) == "LLM_API_KEY / OPENAI_API_KEY 未配置" + else: + raise AssertionError("expected ValueError when no API key is configured") + + +def test_oasis_profile_generator_english_prompts_switch_user_facing_language(): + generator = OasisProfileGenerator.__new__(OasisProfileGenerator) + generator.locale = "en" + + system_prompt = generator._get_system_prompt(is_individual=True) + user_prompt = generator._build_individual_persona_prompt( + entity_name="Alice", + entity_type="Player", + entity_summary="A strategy-game enthusiast.", + entity_attributes={"region": "US"}, + context="Forum comments and profile notes.", + ) + + assert "Write all user-facing text fields in English." in system_prompt + assert "Use English for all user-facing fields except gender values" in user_prompt + assert "country name in English" in user_prompt + + +def test_oasis_profile_generator_english_prompts_localize_empty_fallbacks(): + generator = OasisProfileGenerator.__new__(OasisProfileGenerator) + generator.locale = "en" + + individual_prompt = generator._build_individual_persona_prompt( + entity_name="Alice", + entity_type="Player", + entity_summary="A strategy-game enthusiast.", + entity_attributes={}, + context="", + ) + group_prompt = generator._build_group_persona_prompt( + entity_name="Example University", + entity_type="University", + entity_summary="A research university.", + entity_attributes={}, + context="", + ) + + assert "Entity attributes: None" in individual_prompt + assert "No additional context provided" in individual_prompt + assert "无额外上下文" not in individual_prompt + assert "Entity attributes: None" in group_prompt + assert "No additional context provided" in group_prompt + assert "无额外上下文" not in group_prompt + + +def test_oasis_profile_generator_english_rule_based_group_profile_uses_english_country(): + generator = OasisProfileGenerator.__new__(OasisProfileGenerator) + generator.locale = "en" + + profile = generator._generate_profile_rule_based( + entity_name="Example University", + entity_type="University", + entity_summary="A research university.", + entity_attributes={}, + ) + + assert profile["country"] == "China" + assert profile["bio"] == "Official account of Example University." + assert profile["persona"].startswith("Example University is an institutional entity") + assert profile["interested_topics"] == ["Public Policy", "Community", "Official Announcements"] + + +def test_oasis_profile_generator_rule_based_group_profile_uses_zh_copy(): + generator = OasisProfileGenerator.__new__(OasisProfileGenerator) + generator.locale = "zh" + + profile = generator._generate_profile_rule_based( + entity_name="示例大学", + entity_type="University", + entity_summary="一所研究型大学。", + entity_attributes={}, + ) + + assert profile["country"] == "中国" + assert profile["bio"] == "示例大学的官方账号。" + assert profile["persona"].startswith("示例大学是一个机构主体") + assert profile["interested_topics"] == ["公共政策", "社区事务", "官方公告"] + + +def test_oasis_profile_generator_rule_based_student_profile_localizes_copy(): + generator = OasisProfileGenerator.__new__(OasisProfileGenerator) + generator.locale = "zh" + + profile = generator._generate_profile_rule_based( + entity_name="小王", + entity_type="Student", + entity_summary="", + entity_attributes={}, + ) + + assert profile["bio"] == "关注学术与社会议题的Student" + assert profile["persona"].startswith("小王是一名积极参与学术与社会讨论的Student") + assert profile["profession"] == "学生" + assert profile["interested_topics"] == ["教育", "社会议题", "科技"] + + +def test_oasis_profile_generator_save_profiles_defaults_country_by_locale(tmp_path): + generator = OasisProfileGenerator.__new__(OasisProfileGenerator) + generator.locale = "en" + output_path = tmp_path / "profiles.json" + + generator.save_profiles( + [ + OasisAgentProfile( + user_id=1, + name="Alice", + user_name="alice", + bio="Bio", + persona="Persona", + karma=1000, + created_at="2026-03-11", + age=30, + gender="female", + mbti="INTJ", + country=None, + profession="Engineer", + interested_topics=["Games"], + ) + ], + str(output_path), + platform="reddit", + ) + + payload = json.loads(output_path.read_text(encoding="utf-8")) + assert payload[0]["country"] == "China" + + +def test_oasis_profile_generator_default_country_tolerates_uninitialized_locale(): + generator = OasisProfileGenerator.__new__(OasisProfileGenerator) + + assert generator._default_country() == "中国" + + +def test_oasis_profile_normalizes_structured_fields_before_serialization(tmp_path): + generator = OasisProfileGenerator.__new__(OasisProfileGenerator) + generator.locale = "en" + output_path = tmp_path / "profiles.json" + + generator.save_profiles( + [ + OasisAgentProfile( + user_id=1, + name="Alice", + user_name="alice", + bio={"summary": "Researcher", "traits": ["curious", "careful"]}, + persona={"role": "Analyst", "focus": ["policy", "risk"]}, + country=["United States", "Canada"], + profession={"title": "Engineer"}, + interested_topics=["Games", {"topic": "Policy"}], + ) + ], + str(output_path), + platform="reddit", + ) + + payload = json.loads(output_path.read_text(encoding="utf-8")) + assert payload[0]["bio"] == "summary: Researcher; traits: curious, careful" + assert payload[0]["persona"] == "role: Analyst; focus: policy, risk" + assert payload[0]["country"] == "United States, Canada" + assert payload[0]["profession"] == "title: Engineer" + assert payload[0]["interested_topics"] == ["Games", "topic: Policy"] + + +def test_oasis_profile_generator_save_twitter_profiles_tolerates_structured_fields(tmp_path): + generator = OasisProfileGenerator.__new__(OasisProfileGenerator) + generator.locale = "en" + output_path = tmp_path / "profiles.csv" + + generator.save_profiles( + [ + OasisAgentProfile( + user_id=1, + name="Alice", + user_name="alice", + bio={"summary": "Researcher"}, + persona={"tone": "Measured"}, + ) + ], + str(output_path), + platform="twitter", + ) + + rows = output_path.read_text(encoding="utf-8").splitlines() + assert rows[0] == "user_id,name,username,user_char,description" + assert "summary: Researcher tone: Measured" in rows[1] + assert rows[1].endswith(",summary: Researcher") + + +def test_oasis_profile_generator_english_progress_messages(monkeypatch): + info_messages = [] + warning_messages = [] + error_messages = [] + fake_logger = SimpleNamespace( + info=info_messages.append, + warning=warning_messages.append, + error=error_messages.append, + debug=lambda *_: None, + ) + monkeypatch.setattr("app.services.oasis_profile_generator.logger", fake_logger) + + generator = OasisProfileGenerator.__new__(OasisProfileGenerator) + generator.locale = "en" + generator._generate_username = lambda name: name.lower() + generator._print_generated_profile = lambda *args, **kwargs: None + generator.generate_profile_from_entity = lambda entity, user_id, use_llm: OasisAgentProfile( + user_id=user_id, + name=entity.name, + user_name=entity.name.lower(), + bio=f"{entity.get_entity_type()}: {entity.name}", + persona="A participant in social discussions.", + ) + + entity = SimpleNamespace( + name="Alice", + summary="Strategy gamer", + uuid="uuid-1", + get_entity_type=lambda: "Person", + ) + progress_messages = [] + + profiles = generator.generate_profiles_from_entities( + entities=[entity], + use_llm=False, + parallel_count=1, + progress_callback=lambda current, total, message: progress_messages.append((current, total, message)), + ) + + assert len(profiles) == 1 + assert progress_messages == [(1, 1, "Completed 1/1: Alice (Person)")] + assert info_messages[0] == "Starting parallel generation for 1 agent profiles (parallelism: 1)..." + assert info_messages[-1] == "[1/1] Successfully generated a profile: Alice (Person)" + assert warning_messages == [] + assert error_messages == [] + + +def test_oasis_profile_generator_english_console_profile_output(monkeypatch): + generator = OasisProfileGenerator.__new__(OasisProfileGenerator) + generator.locale = "en" + + printed = [] + monkeypatch.setattr("builtins.print", lambda *args, **kwargs: printed.append(" ".join(str(arg) for arg in args))) + + generator._print_generated_profile( + "Alice", + "Person", + OasisAgentProfile( + user_id=1, + name="Alice", + user_name="alice", + bio="Bio", + persona="Persona", + age=30, + gender="female", + mbti="INTJ", + country="China", + profession="Engineer", + interested_topics=["Games", "Policy"], + ), + ) + + output = "\n".join(printed) + assert "[Generated] Alice (Person)" in output + assert "Username: alice" in output + assert "Bio" in output + assert "Detailed Persona" in output + assert "Core Attributes" in output + assert "Profession: Engineer | Country: China" in output + assert "Interested Topics: Games, Policy" in output + + +def test_oasis_profile_generator_english_zep_context_scaffolding(monkeypatch): + info_messages = [] + warning_messages = [] + debug_messages = [] + fake_logger = SimpleNamespace( + info=info_messages.append, + warning=warning_messages.append, + error=lambda *_: None, + debug=debug_messages.append, + ) + monkeypatch.setattr("app.services.oasis_profile_generator.logger", fake_logger) + + edge_result = SimpleNamespace(edges=[SimpleNamespace(fact="Alice supports the launch")]) + node_result = SimpleNamespace( + nodes=[ + SimpleNamespace(name="Bob", summary="A longtime collaborator."), + SimpleNamespace(name="Alice", summary="Ignored self summary."), + ] + ) + + class FakeGraph: + def search(self, *, query, graph_id, limit, scope, reranker): + assert graph_id == "graph-1" + assert reranker == "rrf" + assert "All available information" in query + return edge_result if scope == "edges" else node_result + + generator = OasisProfileGenerator.__new__(OasisProfileGenerator) + generator.locale = "en" + generator.graph_id = "graph-1" + generator.zep_client = SimpleNamespace(graph=FakeGraph()) + + entity = SimpleNamespace(name="Alice") + results = generator._search_zep_for_entity(entity) + + assert results["facts"] == ["Alice supports the launch"] + assert "Facts:" in results["context"] + assert "Related entities:" in results["context"] + assert "- Related entity: Bob" in results["context"] + assert warning_messages == [] + assert debug_messages == [] + assert info_messages[-1].startswith("Completed Zep hybrid retrieval: Alice, fetched 1 facts and ") + assert info_messages[-1].endswith(" related nodes") + + +def test_oasis_profile_generator_english_build_entity_context_headings(): + generator = OasisProfileGenerator.__new__(OasisProfileGenerator) + generator.locale = "en" + generator._search_zep_for_entity = lambda entity: { + "facts": ["Alice appeared at the launch event"], + "node_summaries": ["Related entity: Bob"], + "context": "", + } + + entity = SimpleNamespace( + name="Alice", + attributes={"role": "Strategist"}, + related_edges=[ + {"fact": "Alice advised the campaign"}, + {"edge_name": "KNOWS", "direction": "outgoing"}, + ], + related_nodes=[ + {"name": "Bob", "labels": ["Entity", "Analyst"], "summary": "Tracks policy shifts."}, + ], + ) + + context = generator._build_entity_context(entity) + + assert "### Entity attributes" in context + assert "### Relevant facts and relationships" in context + assert "### Related entity information" in context + assert "### Facts retrieved from Zep" in context + assert "### Related nodes retrieved from Zep" in context + assert "Alice --[KNOWS]--> (related entity)" in context + + +def test_simulation_config_generator_missing_api_key_mentions_openai_alias(monkeypatch): + monkeypatch.setattr("app.services.simulation_config_generator.Config.LLM_API_KEY", "") + + try: + SimulationConfigGenerator() + except ValueError as exc: + assert str(exc) == "LLM_API_KEY / OPENAI_API_KEY 未配置" + else: + raise AssertionError("expected ValueError when no API key is configured") + + +def test_simulation_config_generator_missing_api_key_english_message(monkeypatch): + monkeypatch.setattr("app.services.simulation_config_generator.Config.LLM_API_KEY", "") + + try: + SimulationConfigGenerator(locale="en") + except ValueError as exc: + assert str(exc) == "LLM_API_KEY / OPENAI_API_KEY is not configured" + else: + raise AssertionError("expected ValueError when no API key is configured") + + +def test_simulation_config_generator_english_prompts_switch_user_facing_language(): + generator = SimulationConfigGenerator.__new__(SimulationConfigGenerator) + generator.locale = "en" + generator.TIME_CONFIG_CONTEXT_LENGTH = 10000 + generator.EVENT_CONFIG_CONTEXT_LENGTH = 8000 + generator.AGENT_SUMMARY_LENGTH = 300 + + time_prompt, time_system = generator._build_time_config_prompt("## Simulation Requirement\nLaunch a new game.", 12) + event_prompt, event_system = generator._build_event_config_prompt( + context="## Simulation Requirement\nLaunch a new game.", + simulation_requirement="Predict the target audience for a new strategy game.", + type_info="- Student: Alice, Bob", + ) + agent_prompt, agent_system = generator._build_agent_config_prompt( + entity_list=[{"agent_id": 0, "entity_name": "Alice", "entity_type": "Student", "summary": "Strategy gamer"}], + simulation_requirement="Predict the target audience for a new strategy game.", + ) + + assert "Generate a time-configuration JSON for this social simulation." in time_prompt + assert "Write the reasoning text in English." in time_prompt + assert "Return strict JSON only." in time_system + assert "Any free-text output fields must be written in English." in time_system + assert "Generate the event configuration JSON for this simulation." in event_prompt + assert "Write `narrative_direction`, every `initial_posts[].content` value, and `reasoning` in English." in event_prompt + assert "poster_type must match one of the available entity types exactly" in event_system + assert "Any free-text output fields must be written in English." in event_system + assert "Generate social-media activity configurations for each entity below." in agent_prompt + assert "social-media behavior analyst" in agent_system + + +def test_simulation_config_generator_logs_english_time_config_adjustments(monkeypatch): + messages = [] + fake_logger = SimpleNamespace(warning=messages.append) + monkeypatch.setattr(simulation_config_generator_module, "logger", fake_logger) + + generator = SimulationConfigGenerator.__new__(SimulationConfigGenerator) + generator.locale = "en" + + parsed = generator._parse_time_config( + { + "agents_per_hour_min": 10, + "agents_per_hour_max": 11, + }, + num_entities=4, + ) + + assert parsed.agents_per_hour_min == 1 + assert parsed.agents_per_hour_max == 2 + assert messages == [ + "agents_per_hour_min (10) exceeded the total agent count (4); adjusted automatically", + "agents_per_hour_max (11) exceeded the total agent count (4); adjusted automatically", + ] + + +def test_simulation_config_generator_logs_english_min_ge_max_adjustment(monkeypatch): + messages = [] + fake_logger = SimpleNamespace(warning=messages.append) + monkeypatch.setattr(simulation_config_generator_module, "logger", fake_logger) + + generator = SimulationConfigGenerator.__new__(SimulationConfigGenerator) + generator.locale = "en" + + parsed = generator._parse_time_config( + { + "agents_per_hour_min": 4, + "agents_per_hour_max": 4, + }, + num_entities=10, + ) + + assert parsed.agents_per_hour_min == 2 + assert parsed.agents_per_hour_max == 4 + assert messages == [ + "agents_per_hour_min was >= max; adjusted to 2", + ] + + +def test_simulation_config_generator_logs_english_initial_post_assignment(monkeypatch): + info_messages = [] + warning_messages = [] + fake_logger = SimpleNamespace( + info=info_messages.append, + warning=warning_messages.append, + ) + monkeypatch.setattr(simulation_config_generator_module, "logger", fake_logger) + + generator = SimulationConfigGenerator.__new__(SimulationConfigGenerator) + generator.locale = "en" + + event_config = generator._parse_event_config( + { + "initial_posts": [ + {"content": "Official update", "poster_type": "Official"}, + {"content": "Unexpected voice", "poster_type": "Alien"}, + ] + } + ) + agent_configs = [ + SimpleNamespace(agent_id=7, entity_type="University", influence_weight=3.0), + SimpleNamespace(agent_id=4, entity_type="Student", influence_weight=1.0), + ] + + updated = generator._assign_initial_post_agents(event_config, agent_configs) + + assert [post["poster_agent_id"] for post in updated.initial_posts] == [7, 7] + assert warning_messages == [ + "No matching agent found for poster_type 'alien'; using the highest-influence agent" + ] + assert info_messages == [ + "Initial post assignment: poster_type='official' -> agent_id=7", + "Initial post assignment: poster_type='alien' -> agent_id=7", + ] + + +def test_simulation_config_generator_logs_english_truncated_output_warning(monkeypatch): + warning_messages = [] + fake_logger = SimpleNamespace(warning=warning_messages.append) + monkeypatch.setattr(simulation_config_generator_module, "logger", fake_logger) + + class FakeCompletions: + def create(self, **kwargs): + return _make_response('{"time_config": {"total_simulation_hours": 12}', finish_reason="length") + + generator = SimulationConfigGenerator.__new__(SimulationConfigGenerator) + generator.model_name = "test-model" + generator.locale = "en" + generator.client = SimpleNamespace(chat=SimpleNamespace(completions=FakeCompletions())) + generator._fix_truncated_json = lambda content: content + "}" + + result = generator._request_json_completion( + messages=[{"role": "user", "content": "hello"}], + temperature=0.6, + ) + + assert result["content"] == '{"time_config": {"total_simulation_hours": 12}}' + assert warning_messages == ["LLM output was truncated; attempting to repair JSON..."] + + +def test_simulation_config_generator_localizes_unknown_entity_labels_in_context(): + generator = SimulationConfigGenerator.__new__(SimulationConfigGenerator) + generator.locale = "zh" + generator.ENTITIES_PER_TYPE_DISPLAY = 20 + generator.ENTITY_SUMMARY_LENGTH = 300 + + summary = generator._summarize_entities( + [ + SimpleNamespace( + name="未命名实体", + summary="一段摘要", + get_entity_type=lambda: None, + ) + ] + ) + + assert "### 未知 (1个)" in summary + assert "- 未命名实体: 一段摘要" in summary + + +def test_simulation_config_generator_localizes_unknown_defaults_in_agent_configs_and_posts(): + generator = SimulationConfigGenerator.__new__(SimulationConfigGenerator) + generator.locale = "zh" + generator.AGENT_SUMMARY_LENGTH = 300 + generator._call_llm_with_retry = lambda prompt, system_prompt: {} + + entity = SimpleNamespace( + uuid="entity-1", + name="匿名角色", + summary="背景摘要", + get_entity_type=lambda: None, + ) + + configs = generator._generate_agent_configs_batch( + context="背景", + entities=[entity], + start_idx=0, + simulation_requirement="测试需求", + ) + assigned = generator._assign_initial_post_agents( + EventConfig(initial_posts=[{"content": "测试帖子"}]), + configs, + ) + + assert configs[0].entity_type == "未知" + assert assigned.initial_posts[0]["poster_type"] == "未知" + + +def test_zep_services_missing_key_support_english_request_locale(monkeypatch): + monkeypatch.setattr("app.services.graph_builder.Config.ZEP_API_KEY", "") + monkeypatch.setattr("app.services.zep_entity_reader.Config.ZEP_API_KEY", "") + monkeypatch.setattr("app.services.zep_graph_memory_updater.Config.ZEP_API_KEY", "") + monkeypatch.setattr("app.services.zep_tools.Config.ZEP_API_KEY", "") + + from flask import Flask + + app = Flask(__name__) + with app.test_request_context(headers={"X-Locale": "en"}): + constructors = ( + (GraphBuilderService, (), {}), + (ZepEntityReader, (), {}), + (ZepGraphMemoryUpdater, ("graph_123",), {}), + (ZepToolsService, (), {}), + ) + for service_type, args, kwargs in constructors: + try: + service_type(*args, **kwargs) + except ValueError as exc: + assert str(exc) == "ZEP_API_KEY is not configured" + else: + raise AssertionError(f"expected ValueError for {service_type.__name__}") + + +def test_agent_activity_episode_text_respects_english_locale(): + activity = AgentActivity( + platform="twitter", + agent_id=1, + agent_name="Alice", + action_type="QUOTE_POST", + action_args={ + "original_author_name": "Bob", + "original_content": "Launch day is tomorrow", + "quote_content": "I agree", + }, + round_num=1, + timestamp="2026-03-11T12:00:00", + locale="en", + ) + + assert ( + activity.to_episode_text() + == 'Alice: quoted Bob\'s post "Launch day is tomorrow", adding: "I agree"' + ) + + +def test_zep_graph_memory_updater_localizes_english_runtime_logs(monkeypatch): + info_messages = [] + warning_messages = [] + error_messages = [] + debug_messages = [] + + fake_logger = SimpleNamespace( + info=info_messages.append, + warning=warning_messages.append, + error=error_messages.append, + debug=debug_messages.append, + ) + monkeypatch.setattr("app.services.zep_graph_memory_updater.logger", fake_logger) + monkeypatch.setattr("app.services.zep_graph_memory_updater.Config.ZEP_API_KEY", "zep-test-key") + + add_calls = [] + + class FakeGraph: + def add(self, **kwargs): + add_calls.append(kwargs) + + class FakeThread: + def __init__(self, target, daemon, name): + self.target = target + self.daemon = daemon + self.name = name + + def start(self): + return None + + def is_alive(self): + return False + + def join(self, timeout=None): + return None + + monkeypatch.setattr( + "app.services.zep_graph_memory_updater.Zep", + lambda api_key: SimpleNamespace(graph=FakeGraph()), + ) + monkeypatch.setattr("app.services.zep_graph_memory_updater.threading.Thread", FakeThread) + + activity = AgentActivity( + platform="twitter", + agent_id=1, + agent_name="Alice", + action_type="CREATE_POST", + action_args={"content": "Launch day"}, + round_num=1, + timestamp="2026-03-11T12:00:00", + locale="en", + ) + + updater = ZepGraphMemoryUpdater("graph_123", locale="en") + updater.start() + updater.add_activity(activity) + updater._send_batch_activities([activity], "twitter") + updater.stop() + + ZepGraphMemoryManager._updaters = {} + ZepGraphMemoryManager._stop_all_done = False + ZepGraphMemoryManager.create_updater("sim-1", "graph_456", locale="en") + ZepGraphMemoryManager.stop_updater("sim-1") + + combined_messages = info_messages + warning_messages + error_messages + debug_messages + + assert add_calls + assert any("ZepGraphMemoryUpdater initialized" in message for message in info_messages) + assert any("ZepGraphMemoryUpdater started" in message for message in info_messages) + assert any("Queued Zep activity" in message for message in debug_messages) + assert any("Sent 1 World 1 activities to graph graph_123" in message for message in info_messages) + assert any("Created graph memory updater: simulation_id=sim-1, graph_id=graph_456" in message for message in info_messages) + assert any("Stopped graph memory updater: simulation_id=sim-1" in message for message in info_messages) + assert all(not any("\u4e00" <= ch <= "\u9fff" for ch in message) for message in combined_messages) + + +def test_zep_graph_memory_updater_missing_key_uses_requested_locale(monkeypatch): + monkeypatch.setattr("app.services.zep_graph_memory_updater.Config.ZEP_API_KEY", "") + + try: + ZepGraphMemoryUpdater("graph_123", locale="en") + except ValueError as exc: + assert str(exc) == "ZEP_API_KEY is not configured" + else: + raise AssertionError("expected ValueError when ZEP_API_KEY is missing") + + +def test_zep_graph_memory_manager_stop_all_prefers_updater_locale(monkeypatch): + info_messages = [] + fake_logger = SimpleNamespace( + info=info_messages.append, + warning=lambda *_: None, + error=lambda *_: None, + debug=lambda *_: None, + ) + monkeypatch.setattr("app.services.zep_graph_memory_updater.logger", fake_logger) + monkeypatch.setattr("app.services.zep_graph_memory_updater.get_locale", lambda: "zh") + + stopped = [] + + class FakeUpdater: + def __init__(self, locale): + self.locale = locale + + def stop(self): + stopped.append(self.locale) + + ZepGraphMemoryManager._updaters = {"sim-en": FakeUpdater("en")} + ZepGraphMemoryManager._stop_all_done = False + + ZepGraphMemoryManager.stop_all() + + assert stopped == ["en"] + assert info_messages[-1] == "Stopped all graph memory updaters" + assert all(not any("\u4e00" <= ch <= "\u9fff" for ch in message) for message in info_messages) + + ZepGraphMemoryManager._updaters = {} + ZepGraphMemoryManager._stop_all_done = False diff --git a/backend/tests/test_parallel_simulation_script.py b/backend/tests/test_parallel_simulation_script.py new file mode 100644 index 00000000..60a71d9a --- /dev/null +++ b/backend/tests/test_parallel_simulation_script.py @@ -0,0 +1,50 @@ +import importlib.util +import sys +from pathlib import Path + + +def load_parallel_simulation_module(monkeypatch, locale="en"): + scripts_dir = Path(__file__).resolve().parents[1] / "scripts" + module_path = scripts_dir / "run_parallel_simulation.py" + module_name = "test_run_parallel_simulation_module" + + monkeypatch.setenv("MIROFISH_LOCALE", locale) + monkeypatch.syspath_prepend(str(scripts_dir)) + sys.modules.pop(module_name, None) + + spec = importlib.util.spec_from_file_location(module_name, module_path) + assert spec is not None + assert spec.loader is not None + + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_fetch_new_actions_from_db_localizes_read_failures(monkeypatch, tmp_path, capsys): + module = load_parallel_simulation_module(monkeypatch, locale="en") + broken_db = tmp_path / "broken.db" + broken_db.write_text("not a sqlite database", encoding="utf-8") + + actions, last_rowid = module.fetch_new_actions_from_db(str(broken_db), 0, {}) + + assert actions == [] + assert last_rowid == 0 + assert "Failed to read database actions:" in capsys.readouterr().out + + +def test_enrich_action_context_localizes_failures(monkeypatch, capsys): + module = load_parallel_simulation_module(monkeypatch, locale="en") + + class FailingCursor: + def execute(self, *_args, **_kwargs): + raise RuntimeError("boom") + + module._enrich_action_context( + FailingCursor(), + "FOLLOW", + {"follow_id": 1}, + {}, + ) + + assert "Failed to enrich action context: boom" in capsys.readouterr().out diff --git a/backend/tests/test_print_config_status.py b/backend/tests/test_print_config_status.py new file mode 100644 index 00000000..3505531a --- /dev/null +++ b/backend/tests/test_print_config_status.py @@ -0,0 +1,173 @@ +import importlib.util +import json +import os +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace + + +def load_module(): + module_path = Path(__file__).resolve().parents[1] / "scripts" / "print_config_status.py" + module_name = "test_print_config_status_module" + sys.modules.pop(module_name, None) + spec = importlib.util.spec_from_file_location(module_name, module_path) + assert spec is not None + assert spec.loader is not None + + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _validation_payload(*, is_valid: bool, errors: list[str] | None = None) -> SimpleNamespace: + errors = errors or [] + return SimpleNamespace( + is_valid=is_valid, + to_dict=lambda: { + "is_valid": is_valid, + "errors": errors, + "warnings": [], + "info": [], + "error_count": len(errors), + "warning_count": 0, + }, + ) + + +def test_build_payload_matches_backend_config_status_shape(monkeypatch): + module = load_module() + fake_validation = _validation_payload(is_valid=True) + seen = {} + + def validate(locale="zh"): + seen["locale"] = locale + return fake_validation + + monkeypatch.setattr( + module, + "Config", + SimpleNamespace( + validate_comprehensive=validate, + get_config_summary=lambda: { + "llm": { + "backend_mode": "openai_compatible", + "sources": {"api_key_env": "OPENAI_API_KEY"}, + } + }, + ), + ) + + payload = module.build_payload("en") + + assert seen["locale"] == "en" + assert payload == { + "success": True, + "data": { + "validation": fake_validation.to_dict(), + "summary": { + "llm": { + "backend_mode": "openai_compatible", + "sources": {"api_key_env": "OPENAI_API_KEY"}, + } + }, + }, + } + + +def test_load_config_class_avoids_importing_flask_app_package(): + original_app = sys.modules.pop("app", None) + try: + module = load_module() + + assert module.Config.__module__ == "_mirofish_script_app.config" + assert "app" not in sys.modules + finally: + if original_app is not None: + sys.modules["app"] = original_app + + +def test_main_returns_nonzero_when_config_is_invalid(monkeypatch, capsys): + module = load_module() + fake_validation = _validation_payload( + is_valid=False, + errors=["LLM_API_KEY / OPENAI_API_KEY is not configured"], + ) + + monkeypatch.setattr( + module, + "Config", + SimpleNamespace( + validate_comprehensive=lambda locale="zh": fake_validation, + get_config_summary=lambda: {"llm": {"configured": False}}, + ), + ) + + exit_code = module.main(["--locale", "en", "--compact"]) + captured = capsys.readouterr() + + assert exit_code == 1 + payload = json.loads(captured.out) + assert payload["success"] is False + assert payload["data"]["validation"]["errors"] == [ + "LLM_API_KEY / OPENAI_API_KEY is not configured" + ] + + +def test_print_config_status_script_accepts_openai_aliases_end_to_end(): + script_path = Path(__file__).resolve().parents[1] / "scripts" / "print_config_status.py" + env = { + **os.environ, + "LLM_API_KEY": "", + "LLM_BASE_URL": "", + "LLM_MODEL_NAME": "", + "OPENAI_API_KEY": "codex-test-key", + "OPENAI_BASE_URL": "", + "OPENAI_API_BASE_URL": "https://codex.example.test/v1", + "OPENAI_MODEL": "gpt-4.1-mini", + "ZEP_API_KEY": "zep-test-key", + } + + result = subprocess.run( + [sys.executable, str(script_path), "--locale", "en", "--compact"], + check=False, + capture_output=True, + text=True, + env=env, + ) + + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout) + assert payload["success"] is True + assert payload["data"]["summary"]["llm"] == { + "backend_mode": "openai_compatible", + "base_url": "https://codex.example.test/v1", + "model": "gpt-4.1-mini", + "max_tokens": 4096, + "configured": True, + "sources": { + "api_key_env": "OPENAI_API_KEY", + "base_url_env": "OPENAI_API_BASE_URL", + "model_env": "OPENAI_MODEL", + "base_url_conflict": None, + "uses_project_aliases": False, + "uses_openai_aliases": True, + }, + } + assert payload["data"]["summary"]["capabilities"] == { + "direct_llm": { + "ready": True, + }, + "graph_build": { + "ready": True, + "requires_zep": True, + }, + "graph_report_tools": { + "ready": True, + "requires_zep": True, + }, + "existing_simulation_interaction": { + "ready": True, + "requires_existing_simulation": True, + }, + } diff --git a/backend/tests/test_report_agent.py b/backend/tests/test_report_agent.py new file mode 100644 index 00000000..77251fe0 --- /dev/null +++ b/backend/tests/test_report_agent.py @@ -0,0 +1,708 @@ +import json +import sys +from types import ModuleType + +fake_zep_cloud = ModuleType("zep_cloud") +fake_zep_client = ModuleType("zep_cloud.client") +fake_zep_client.Zep = object +fake_zep_cloud.client = fake_zep_client +fake_zep_cloud.__getattr__ = lambda name: object +sys.modules.setdefault("zep_cloud", fake_zep_cloud) +sys.modules.setdefault("zep_cloud.client", fake_zep_client) + +from app.services.report_agent import ReportAgent +from app.services.report_agent import Report +from app.services.report_agent import ReportManager +from app.services.report_agent import ReportOutline +from app.services.report_agent import ReportSection +from app.services.report_agent import ReportStatus + + +class FakeLLM: + def __init__(self): + self.messages = None + self.temperature = None + + def chat_json(self, messages, temperature): + self.messages = messages + self.temperature = temperature + return { + "title": "游戏受众分析与预测", + "summary": "核心玩家更偏向剧情驱动和策略投入并重的受众组合。", + "sections": [ + {"title": "潜在人群画像"}, + {"title": "传播与留存风险"}, + ], + } + + +class AbstractTitleLLM(FakeLLM): + def chat_json(self, messages, temperature): + self.messages = messages + self.temperature = temperature + return { + "title": "未来受众群体生态的静默与解体:一项基于模拟的预测报告", + "summary": "核心玩家更偏向剧情驱动和策略投入并重的受众组合。", + "sections": [ + {"title": "潜在人群画像"}, + {"title": "传播与留存风险"}, + ], + } + + +class FailingLLM(FakeLLM): + def chat_json(self, messages, temperature): + raise RuntimeError("provider offline") + + +class EmptySectionLLM(FakeLLM): + def chat_json(self, messages, temperature): + self.messages = messages + self.temperature = temperature + return { + "title": "游戏受众分析与预测", + "summary": "核心玩家更偏向剧情驱动和策略投入并重的受众组合。", + "sections": [ + {"title": "潜在人群画像"}, + ], + } + + def chat(self, messages, temperature, max_tokens, response_format=None): + self.messages = messages + self.temperature = temperature + return None + + +class SequenceSectionLLM(FakeLLM): + def __init__(self, responses): + super().__init__() + self.responses = list(responses) + self.calls = [] + + def chat(self, messages, temperature, max_tokens, response_format=None): + snapshot = [{"role": item["role"], "content": item["content"]} for item in messages] + self.calls.append(snapshot) + self.messages = snapshot + self.temperature = temperature + if not self.responses: + raise AssertionError("SequenceSectionLLM ran out of scripted responses") + return self.responses.pop(0) + + +class SequenceChatLLM(FakeLLM): + def __init__(self, responses): + super().__init__() + self.responses = list(responses) + self.calls = [] + + def chat(self, messages, temperature, max_tokens=None, response_format=None): + snapshot = [{"role": item["role"], "content": item["content"]} for item in messages] + self.calls.append(snapshot) + self.messages = snapshot + self.temperature = temperature + if not self.responses: + raise AssertionError("SequenceChatLLM ran out of scripted responses") + return self.responses.pop(0) + + +class FakeZepTools: + def get_simulation_context(self, graph_id, simulation_requirement): + return { + "graph_statistics": { + "total_nodes": 12, + "total_edges": 21, + "entity_types": {"玩家": 8, "媒体": 4}, + }, + "total_entities": 12, + "related_facts": [ + {"fact": "剧情向玩家更关注世界观完整度"}, + {"fact": "策略向玩家更在意长期成长反馈"}, + ], + } + + +class CapturingLogger: + def __init__(self): + self.debugs = [] + + def debug(self, message, *args): + if args: + message = message % args + self.debugs.append(message) + + def info(self, message, *args): + pass + + def warning(self, message, *args): + pass + + def error(self, message, *args): + pass + + +def test_plan_outline_sends_readability_constraints_in_prompt(): + llm = FakeLLM() + agent = ReportAgent( + graph_id="graph-test", + simulation_id="sim-test", + simulation_requirement="预测这个游戏的受众群体会是什么样", + llm_client=llm, + zep_tools=FakeZepTools(), + ) + + outline = agent.plan_outline() + + assert outline.title == "游戏受众分析与预测" + assert [section.title for section in outline.sections] == ["潜在人群画像", "传播与留存风险"] + assert llm.temperature == 0.3 + assert llm.messages is not None + + system_prompt = llm.messages[0]["content"] + user_prompt = llm.messages[1]["content"] + + assert "标题与摘要要求" in system_prompt + assert "标题要简洁、直白、可读" in system_prompt + assert "禁止使用与用户问题脱节的抽象比喻或夸张措辞" in system_prompt + assert "报告标题必须让普通用户直接看懂" in user_prompt + assert "如果模拟需求是在预测某个产品、方案、游戏、事件或人群,就在标题里明确点出该对象" in user_prompt + + +def test_plan_outline_falls_back_from_abstract_title_to_requirement_subject(): + llm = AbstractTitleLLM() + agent = ReportAgent( + graph_id="graph-test", + simulation_id="sim-test", + simulation_requirement="预测这个游戏的受众群体会是什么样", + llm_client=llm, + zep_tools=FakeZepTools(), + ) + + outline = agent.plan_outline() + + assert outline.title == "游戏受众群体分析报告" + + +def test_plan_outline_requests_english_output_when_locale_is_en(): + llm = FakeLLM() + agent = ReportAgent( + graph_id="graph-test", + simulation_id="sim-test", + simulation_requirement="Predict the likely audience for this game", + locale="en", + llm_client=llm, + zep_tools=FakeZepTools(), + ) + + agent.plan_outline() + + assert llm.messages is not None + system_prompt = llm.messages[0]["content"] + user_prompt = llm.messages[1]["content"] + + assert "You are an expert writer of \"future forecast reports\"" in system_prompt + assert "Return the report outline as JSON in this format:" in system_prompt + assert "你是一个" not in system_prompt + assert "[Forecast scenario]" in user_prompt + assert "Review this simulated future from a bird's-eye view:" in user_prompt + assert "Return the report title, summary, and section titles/descriptions in English." in user_prompt + assert "Keep wording concrete, readable, and directly aligned with the simulation requirement." in user_prompt + assert "【预测场景设定】" not in user_prompt + + +def test_plan_outline_english_progress_messages_are_localized(): + llm = FakeLLM() + agent = ReportAgent( + graph_id="graph-test", + simulation_id="sim-test", + simulation_requirement="Predict the likely audience for this game", + locale="en", + llm_client=llm, + zep_tools=FakeZepTools(), + ) + + progress_updates = [] + agent.plan_outline(progress_callback=lambda stage, progress, message: progress_updates.append((stage, progress, message))) + + assert progress_updates == [ + ("planning", 0, "Analyzing the simulation requirement..."), + ("planning", 30, "Generating the report outline..."), + ("planning", 80, "Parsing the outline structure..."), + ("planning", 100, "Outline planning completed"), + ] + + +def test_plan_outline_english_fallback_outline_is_localized(): + agent = ReportAgent( + graph_id="graph-test", + simulation_id="sim-test", + simulation_requirement="Predict the likely audience for this game", + locale="en", + llm_client=FailingLLM(), + zep_tools=FakeZepTools(), + ) + + outline = agent.plan_outline() + + assert outline.title == "Forecast Report" + assert outline.summary == "Trend and risk analysis based on the simulation forecast." + assert [section.title for section in outline.sections] == [ + "Forecast scenarios and key findings", + "Audience behavior analysis", + "Trend outlook and risk signals", + ] + + +def test_report_agent_english_tool_descriptions_are_localized(): + agent = ReportAgent( + graph_id="graph-test", + simulation_id="sim-test", + simulation_requirement="Predict the likely audience for this game", + locale="en", + llm_client=FakeLLM(), + zep_tools=FakeZepTools(), + ) + + tools_description = agent._get_tools_description() + + assert "[Deep insight retrieval - powerful analysis tool]" in tools_description + assert "Question or topic to analyze deeply" in tools_description + assert "[Deep interviews - real agent interviews across both platforms]" in tools_description + assert "Maximum number of agents to interview" in tools_description + assert "深度洞察检索" not in tools_description + assert "采访主题或需求描述" not in tools_description + + +def test_execute_tool_english_errors_are_localized(): + agent = ReportAgent( + graph_id="graph-test", + simulation_id="sim-test", + simulation_requirement="Predict the likely audience for this game", + locale="en", + llm_client=FakeLLM(), + zep_tools=FakeZepTools(), + ) + + unknown = agent._execute_tool("not_a_tool", {}) + assert unknown == "Unknown tool: not_a_tool. Use one of: insight_forge, panorama_search, quick_search" + + class BrokenTools(FakeZepTools): + def quick_search(self, graph_id, query, limit): + raise RuntimeError("search backend unavailable") + + failing_agent = ReportAgent( + graph_id="graph-test", + simulation_id="sim-test", + simulation_requirement="Predict the likely audience for this game", + locale="en", + llm_client=FakeLLM(), + zep_tools=BrokenTools(), + ) + + assert failing_agent._execute_tool("quick_search", {"query": "audience", "limit": 3}) == ( + "Tool execution failed: search backend unavailable" + ) + + +def test_generate_report_survives_empty_llm_section_responses(tmp_path, monkeypatch): + monkeypatch.setattr(ReportManager, "REPORTS_DIR", str(tmp_path / "reports")) + + agent = ReportAgent( + graph_id="graph-test", + simulation_id="sim-test", + simulation_requirement="预测这个游戏的受众群体会是什么样", + llm_client=EmptySectionLLM(), + zep_tools=FakeZepTools(), + ) + + report = agent.generate_report(report_id="report_empty_llm") + + assert report.status == ReportStatus.COMPLETED + assert "本章节生成失败:LLM 返回空响应,请稍后重试" in report.markdown_content + + saved_progress = ReportManager.get_progress("report_empty_llm") + assert saved_progress is not None + assert saved_progress["status"] == "completed" + assert saved_progress["progress"] == 100 + + +def test_assemble_full_report_embeds_localized_reference_block(tmp_path, monkeypatch): + monkeypatch.setattr(ReportManager, "REPORTS_DIR", str(tmp_path / "reports")) + report_id = "report_refs" + ReportManager.save_section( + report_id, + 1, + ReportSection(title="Key Findings", content="Forecast body."), + ) + outline = ReportOutline( + title="Forecast Report", + summary="Audience outlook summary", + sections=[ReportSection(title="Key Findings")], + ) + report = Report( + report_id=report_id, + simulation_id="sim_refs", + graph_id="graph_refs", + simulation_requirement="Predict the likely audience for this game", + status=ReportStatus.GENERATING, + created_at="2026-03-12T02:00:00", + completed_at="2026-03-12T02:05:00", + ) + + markdown = ReportManager.assemble_full_report( + report_id, + outline, + locale="en", + report=report, + ) + + assert "**Report References**" in markdown + assert "| Report ID | report_refs |" in markdown + assert "| Simulation ID | sim_refs |" in markdown + assert "| Graph ID | graph_refs |" in markdown + assert "| Generated At | 2026-03-12T02:05:00 |" in markdown + assert f"| Report Folder | {tmp_path / 'reports' / report_id} |" in markdown + assert f"| Markdown Path | {tmp_path / 'reports' / report_id / 'full_report.md'} |" in markdown + assert "**Simulation Requirement**" in markdown + assert "Predict the likely audience for this game" in markdown + assert "**Manual Verification Checklist**" in markdown + assert "Keep this file plus the report and simulation IDs above as the stable forecast reference." in markdown + assert "When real-world outcomes are available, compare them against the key forecast claims in this report." in markdown + + +def test_generate_report_embeds_reference_block_in_markdown(tmp_path, monkeypatch): + monkeypatch.setattr(ReportManager, "REPORTS_DIR", str(tmp_path / "reports")) + + agent = ReportAgent( + graph_id="graph-test", + simulation_id="sim-test", + simulation_requirement="预测这个游戏的受众群体会是什么样", + llm_client=EmptySectionLLM(), + zep_tools=FakeZepTools(), + ) + + report = agent.generate_report(report_id="report_with_refs") + + assert report.status == ReportStatus.COMPLETED + assert "**报告引用信息**" in report.markdown_content + assert "| 报告 ID | report_with_refs |" in report.markdown_content + assert "| 模拟 ID | sim-test |" in report.markdown_content + assert "| 图谱 ID | graph-test |" in report.markdown_content + assert f"| 报告目录 | {tmp_path / 'reports' / 'report_with_refs'} |" in report.markdown_content + assert f"| Markdown 路径 | {tmp_path / 'reports' / 'report_with_refs' / 'full_report.md'} |" in report.markdown_content + assert "**手动复核清单**" in report.markdown_content + assert "保留本文件以及上面的报告 ID / 模拟 ID 作为后续复核锚点。" in report.markdown_content + + +def test_generate_section_localizes_english_react_loop_messages(monkeypatch): + llm = SequenceSectionLLM([ + '<tool_call>{"name":"quick_search","parameters":{"query":"audience","limit":1}}</tool_call>', + "Final Answer: Too early", + '<tool_call>{"name":"panorama_search","parameters":{"query":"audience","include_expired":true}}</tool_call>', + '<tool_call>{"name":"insight_forge","parameters":{"query":"audience"}}</tool_call>', + "Final Answer: Final English section body", + ]) + agent = ReportAgent( + graph_id="graph-test", + simulation_id="sim-test", + simulation_requirement="Predict the likely audience for this game", + locale="en", + llm_client=llm, + zep_tools=FakeZepTools(), + ) + outline = ReportOutline( + title="Forecast Report", + summary="Audience forecast", + sections=[ReportSection(title="Audience Outlook")], + ) + progress_updates = [] + observed_contexts = [] + + def fake_execute_tool(tool_name, parameters, report_context=None): + observed_contexts.append((tool_name, report_context)) + return f"{tool_name} evidence" + + monkeypatch.setattr(agent, "_execute_tool", fake_execute_tool) + + content = agent._generate_section_react( + outline.sections[0], + outline, + [], + progress_callback=lambda stage, progress, message: progress_updates.append((stage, progress, message)), + section_index=1, + ) + + assert content == "Final English section body" + assert progress_updates[0] == ("generating", 0, "Deep retrieval and drafting in progress (0/5)") + initial_system_prompt = llm.calls[0][0]["content"] + initial_user_prompt = llm.calls[0][1]["content"] + assert "You are an expert writer preparing one section of a future forecast report." in initial_system_prompt + assert "[Core idea]" in initial_system_prompt + assert "Each section must call tools at least 3 times and at most 5 times." in initial_system_prompt + assert "你是一个「未来预测报告」的撰写专家" not in initial_system_prompt + assert "Completed section content (read carefully and avoid repetition):" in initial_user_prompt + assert "[Current task] Write section: Audience Outlook" in initial_user_prompt + assert "Then call a tool to retrieve simulation evidence." in initial_user_prompt + assert "【当前任务】撰写章节" not in initial_user_prompt + assert "(This is the first section)" in llm.calls[0][1]["content"] + assert observed_contexts[0] == ( + "quick_search", + "Section title: Audience Outlook\nSimulation requirement: Predict the likely audience for this game", + ) + + observation_prompt = llm.calls[1][-1]["content"] + assert "Observation:" in observation_prompt + assert "Tool quick_search returned" in observation_prompt + assert "Tool calls used: 1/5" in observation_prompt + + insufficient_tools_prompt = llm.calls[2][-1]["content"] + assert insufficient_tools_prompt.startswith("Notice: you have only used 1 tool calls; at least 3 are required.") + assert "Please call another tool to gather more simulation evidence before outputting Final Answer." in insufficient_tools_prompt + assert "Tip: you have not used these tools yet:" in insufficient_tools_prompt + + +def test_generate_section_localizes_english_llm_preview_debug_logs(monkeypatch): + llm = SequenceSectionLLM([ + '<tool_call>{"name":"quick_search","parameters":{"query":"audience","limit":1}}</tool_call>', + '<tool_call>{"name":"panorama_search","parameters":{"query":"audience","include_expired":true}}</tool_call>', + '<tool_call>{"name":"insight_forge","parameters":{"query":"audience"}}</tool_call>', + "Final Answer: Final English section body", + ]) + logger = CapturingLogger() + monkeypatch.setattr("app.services.report_agent.logger", logger) + + agent = ReportAgent( + graph_id="graph-test", + simulation_id="sim-test", + simulation_requirement="Predict the likely audience for this game", + locale="en", + llm_client=llm, + zep_tools=FakeZepTools(), + ) + outline = ReportOutline( + title="Forecast Report", + summary="Audience forecast", + sections=[ReportSection(title="Audience Outlook")], + ) + monkeypatch.setattr(agent, "_execute_tool", lambda *args, **kwargs: "tool evidence") + + content = agent._generate_section_react( + outline.sections[0], + outline, + [], + section_index=1, + ) + + assert content == "Final English section body" + assert logger.debugs + assert logger.debugs[0].startswith("LLM response preview: <tool_call>") + + +def test_generate_section_localizes_english_empty_response_retry_and_fallback(): + llm = SequenceSectionLLM([None, None, None, None, None, None]) + agent = ReportAgent( + graph_id="graph-test", + simulation_id="sim-test", + simulation_requirement="Predict the likely audience for this game", + locale="en", + llm_client=llm, + zep_tools=FakeZepTools(), + ) + outline = ReportOutline( + title="Forecast Report", + summary="Audience forecast", + sections=[ReportSection(title="Audience Outlook")], + ) + + content = agent._generate_section_react(outline.sections[0], outline, [], section_index=1) + + assert content == "(This section could not be generated because the LLM returned an empty response. Please try again later.)" + assert llm.calls[1][-2]["content"] == "(The response was empty)" + assert llm.calls[1][-1]["content"] == "Please continue generating the content." + + +def test_generate_report_localizes_persisted_agent_log_messages_in_english(tmp_path, monkeypatch): + monkeypatch.setattr(ReportManager, "REPORTS_DIR", str(tmp_path / "reports")) + monkeypatch.setattr("app.services.report_agent.Config.UPLOAD_FOLDER", str(tmp_path)) + + agent = ReportAgent( + graph_id="graph-test", + simulation_id="sim-test", + simulation_requirement="Predict the likely audience for this game", + locale="en", + llm_client=EmptySectionLLM(), + zep_tools=FakeZepTools(), + ) + + report = agent.generate_report(report_id="report_en_logs") + + assert report.status == ReportStatus.COMPLETED + + assert agent.report_logger is not None + log_path = agent.report_logger.log_file_path + entries = [ + json.loads(line) + for line in open(log_path, encoding="utf-8").read().splitlines() + if line.strip() + ] + messages = [entry["details"].get("message") for entry in entries] + + assert "Report generation task started" in messages + assert "Starting report outline planning" in messages + assert "Outline planning completed" in messages + assert "Starting section generation: 潜在人群画像" in messages + assert "Section generation completed: 潜在人群画像" in messages + assert "Report generation completed" in messages + + +def test_generate_report_localizes_console_log_messages_in_english(tmp_path, monkeypatch): + monkeypatch.setattr(ReportManager, "REPORTS_DIR", str(tmp_path / "reports")) + monkeypatch.setattr("app.services.report_agent.Config.UPLOAD_FOLDER", str(tmp_path)) + + agent = ReportAgent( + graph_id="graph-test", + simulation_id="sim-test", + simulation_requirement="Predict the likely audience for this game", + locale="en", + llm_client=EmptySectionLLM(), + zep_tools=FakeZepTools(), + ) + + report = agent.generate_report(report_id="report_en_console") + + assert report.status == ReportStatus.COMPLETED + + console_log_path = tmp_path / "reports" / "report_en_console" / "console_log.txt" + console_output = console_log_path.read_text(encoding="utf-8") + + assert "Starting report outline planning..." in console_output + assert "Outline planning completed: 1 sections" in console_output + assert "Generating section with ReACT: 潜在人群画像" in console_output + assert "Section saved: report_en_console/section_01.md" in console_output + assert "Full report assembled: report_en_console" in console_output + assert "Report generation completed: report_en_console" in console_output + + +def test_chat_localizes_english_scaffolding_without_report(monkeypatch): + llm = SequenceChatLLM([ + '<tool_call>{"name":"quick_search","parameters":{"query":"audience","limit":1}}</tool_call>', + "Final answer in English", + ]) + agent = ReportAgent( + graph_id="graph-test", + simulation_id="sim-test", + simulation_requirement="Predict the likely audience for this game", + locale="en", + llm_client=llm, + zep_tools=FakeZepTools(), + ) + + monkeypatch.setattr(ReportManager, "get_report_by_simulation", lambda simulation_id: None) + monkeypatch.setattr(agent, "_execute_tool", lambda tool_name, parameters: "audience evidence") + + result = agent.chat("Summarize the current audience outlook") + + assert result["response"] == "Final answer in English" + assert "(No report available yet)" in llm.calls[0][0]["content"] + assert "(暂无报告)" not in llm.calls[0][0]["content"] + + observation_prompt = llm.calls[1][-1]["content"] + assert "[Tool quick_search result]" in observation_prompt + assert "Please answer the question concisely." in observation_prompt + assert "[quick_search结果]" not in observation_prompt + + +def test_chat_localizes_english_system_prompt_template(monkeypatch): + llm = SequenceChatLLM(["Final answer in English"]) + agent = ReportAgent( + graph_id="graph-test", + simulation_id="sim-test", + simulation_requirement="Predict the likely audience for this game", + locale="en", + llm_client=llm, + zep_tools=FakeZepTools(), + ) + + monkeypatch.setattr(ReportManager, "get_report_by_simulation", lambda simulation_id: None) + + agent.chat("Summarize the current audience outlook") + + system_prompt = llm.calls[0][0]["content"] + assert "You are a concise and efficient simulation-forecast assistant." in system_prompt + assert "[Rules]" in system_prompt + assert '[Tool call format]' in system_prompt + assert '"name": "tool_name"' in system_prompt + assert "你是一个简洁高效的模拟预测助手" not in system_prompt + assert "工具名称" not in system_prompt + + +def test_chat_localizes_english_truncated_report_marker(monkeypatch): + llm = SequenceChatLLM(["Final answer in English"]) + agent = ReportAgent( + graph_id="graph-test", + simulation_id="sim-test", + simulation_requirement="Predict the likely audience for this game", + locale="en", + llm_client=llm, + zep_tools=FakeZepTools(), + ) + report = type("ReportStub", (), {"markdown_content": "A" * 15010})() + + monkeypatch.setattr(ReportManager, "get_report_by_simulation", lambda simulation_id: report) + + agent.chat("Summarize the current audience outlook") + + system_prompt = llm.calls[0][0]["content"] + assert "... [Report content truncated] ..." in system_prompt + assert "... [报告内容已截断] ..." not in system_prompt + + +def test_chat_returns_localized_fallback_when_initial_response_is_none(monkeypatch): + llm = SequenceChatLLM([None]) + agent = ReportAgent( + graph_id="graph-test", + simulation_id="sim-test", + simulation_requirement="Predict the likely audience for this game", + locale="en", + llm_client=llm, + zep_tools=FakeZepTools(), + ) + + monkeypatch.setattr(ReportManager, "get_report_by_simulation", lambda simulation_id: None) + + result = agent.chat("Summarize the current audience outlook") + + assert result == { + "response": "(The assistant returned an empty response. Please try again.)", + "tool_calls": [], + "sources": [], + } + + +def test_chat_returns_default_locale_fallback_when_final_response_is_none(monkeypatch): + llm = SequenceChatLLM([ + '<tool_call>{"name":"quick_search","parameters":{"query":"受众","limit":1}}</tool_call>', + None, + ]) + agent = ReportAgent( + graph_id="graph-test", + simulation_id="sim-test", + simulation_requirement="预测这个游戏的受众群体会是什么样", + llm_client=llm, + zep_tools=FakeZepTools(), + ) + + monkeypatch.setattr(ReportManager, "get_report_by_simulation", lambda simulation_id: None) + monkeypatch.setattr(agent, "_execute_tool", lambda tool_name, parameters: "受众证据") + + result = agent.chat("总结一下当前受众走向") + + assert result == { + "response": "(助手返回了空响应,请重试。)", + "tool_calls": [ + { + "name": "quick_search", + "parameters": {"query": "受众", "limit": 1}, + } + ], + "sources": ["受众"], + } diff --git a/backend/tests/test_report_api_i18n.py b/backend/tests/test_report_api_i18n.py new file mode 100644 index 00000000..88919a22 --- /dev/null +++ b/backend/tests/test_report_api_i18n.py @@ -0,0 +1,534 @@ +from __future__ import annotations + +import os +import sys +import tempfile +from types import SimpleNamespace +from types import ModuleType + +from flask import Flask +from flask.wrappers import Request + +fake_zep_cloud = ModuleType("zep_cloud") +fake_zep_client = ModuleType("zep_cloud.client") +fake_zep_ontology = ModuleType("zep_cloud.external_clients.ontology") +fake_zep_cloud.client = fake_zep_client +fake_zep_cloud.__getattr__ = lambda name: object + + +class FakeZep: + def __init__(self, *args, **kwargs): + pass + + +fake_zep_client.Zep = FakeZep +fake_zep_ontology.EntityModel = object +fake_zep_ontology.EntityText = object +fake_zep_ontology.EdgeModel = object +sys.modules.setdefault("zep_cloud", fake_zep_cloud) +sys.modules.setdefault("zep_cloud.client", fake_zep_client) +sys.modules.setdefault("zep_cloud.external_clients.ontology", fake_zep_ontology) + +from app.api import report as report_api +from app.api import report_bp +from app.services.report_agent import ReportStatus + + +class FakeLogger: + def __init__(self): + self.errors = [] + self.debugs = [] + + def error(self, message): + self.errors.append(message) + + def debug(self, message): + self.debugs.append(message) + + +def create_report_test_app(): + app = Flask(__name__) + app.register_blueprint(report_bp, url_prefix="/api/report") + return app + + +def test_generate_report_requires_simulation_id_in_english(): + app = create_report_test_app() + client = app.test_client() + + response = client.post( + "/api/report/generate", + json={}, + headers={"X-Locale": "en"}, + ) + + assert response.status_code == 400 + assert response.get_json()["error"] == "Please provide simulation_id" + + +def test_generate_status_requires_task_or_simulation_in_english(): + app = create_report_test_app() + client = app.test_client() + + response = client.post( + "/api/report/generate/status", + json={}, + headers={"X-Locale": "en"}, + ) + + assert response.status_code == 400 + assert response.get_json()["error"] == "Please provide task_id or simulation_id" + + +def test_generate_status_completed_message_is_localized(monkeypatch): + app = create_report_test_app() + client = app.test_client() + + completed_report = SimpleNamespace( + report_id="report_done", + status=ReportStatus.COMPLETED, + ) + monkeypatch.setattr( + report_api.ReportManager, + "get_report_by_simulation", + lambda simulation_id: completed_report, + ) + + response = client.post( + "/api/report/generate/status", + json={"simulation_id": "sim_123"}, + headers={"X-Locale": "en"}, + ) + + payload = response.get_json() + assert response.status_code == 200 + assert payload["data"]["message"] == "The report has already been generated" + assert payload["data"]["already_completed"] is True + + +def test_generate_report_start_message_is_localized(monkeypatch): + app = create_report_test_app() + client = app.test_client() + + report_api.TaskManager()._tasks.clear() + + class ImmediateThread: + def __init__(self, target=None, args=(), kwargs=None, daemon=None): + self._target = target + self._args = args + self._kwargs = kwargs or {} + self.daemon = daemon + + def start(self): + self._target(*self._args, **self._kwargs) + + monkeypatch.setattr(report_api.threading, "Thread", ImmediateThread) + monkeypatch.setattr( + report_api.SimulationManager, + "get_simulation", + lambda self, simulation_id: SimpleNamespace( + project_id="proj_123", + graph_id="graph_123", + ), + ) + monkeypatch.setattr( + report_api.ReportManager, + "get_report_by_simulation", + lambda simulation_id: None, + ) + monkeypatch.setattr( + report_api.Config, + "validate_comprehensive", + classmethod( + lambda cls, locale="zh": SimpleNamespace( + is_valid=True, + errors=[], + warnings=[], + info=[], + to_dict=lambda: { + "is_valid": True, + "errors": [], + "warnings": [], + "info": [], + "error_count": 0, + "warning_count": 0, + }, + ) + ), + ) + monkeypatch.setattr( + report_api.ProjectManager, + "get_project", + lambda project_id: SimpleNamespace( + graph_id="graph_123", + simulation_requirement="Need a report", + ), + ) + monkeypatch.setattr( + report_api.ReportManager, + "save_report", + lambda report: None, + ) + monkeypatch.setattr( + report_api.ReportAgent, + "__init__", + lambda self, graph_id, simulation_id, simulation_requirement, locale: None, + ) + monkeypatch.setattr( + report_api.ReportAgent, + "generate_report", + lambda self, progress_callback, report_id: SimpleNamespace( + report_id=report_id, + status=ReportStatus.COMPLETED, + error=None, + ), + ) + + response = client.post( + "/api/report/generate", + json={"simulation_id": "sim_123"}, + headers={"X-Locale": "en"}, + ) + + payload = response.get_json()["data"] + assert response.status_code == 200 + assert payload["status"] == "generating" + assert payload["message"] == "Report generation has started. Query /api/report/generate/status for progress." + assert payload["already_generated"] is False + + +def test_generate_report_returns_structured_backend_config_error_in_english(monkeypatch): + app = create_report_test_app() + client = app.test_client() + + monkeypatch.setattr( + report_api.SimulationManager, + "get_simulation", + lambda self, simulation_id: SimpleNamespace( + project_id="proj_123", + graph_id="graph_123", + ), + ) + monkeypatch.setattr( + report_api.ReportManager, + "get_report_by_simulation", + lambda simulation_id: None, + ) + monkeypatch.setattr( + report_api.Config, + "validate_comprehensive", + classmethod( + lambda cls, locale="zh": SimpleNamespace( + is_valid=False, + errors=["ZEP_API_KEY is not configured"], + warnings=[], + info=[], + to_dict=lambda: { + "is_valid": False, + "errors": ["ZEP_API_KEY is not configured"], + "warnings": [], + "info": [], + "error_count": 1, + "warning_count": 0, + }, + ) + ), + ) + monkeypatch.setattr( + report_api.Config, + "get_config_summary", + classmethod( + lambda cls: { + "llm": {"backend_mode": "openai_compatible", "configured": True}, + "capabilities": { + "direct_llm": {"ready": True}, + "graph_report_tools": {"ready": False, "requires_zep": True}, + }, + "zep": {"configured": False}, + } + ), + ) + + response = client.post( + "/api/report/generate", + json={"simulation_id": "sim_123"}, + headers={"X-Locale": "en"}, + ) + + payload = response.get_json() + assert response.status_code == 503 + assert payload["error"] == "Backend configuration is incomplete: ZEP_API_KEY is not configured" + assert payload["data"]["validation"]["is_valid"] is False + assert payload["data"]["summary"]["capabilities"]["graph_report_tools"]["ready"] is False + + +def test_generate_status_translates_task_progress_message(monkeypatch): + app = create_report_test_app() + client = app.test_client() + + monkeypatch.setattr( + report_api.TaskManager, + "get_task", + lambda self, task_id: SimpleNamespace( + to_dict=lambda: { + "task_id": task_id, + "status": "processing", + "progress": 30, + "message": "[planning] 正在生成报告大纲...", + } + ), + ) + + response = client.post( + "/api/report/generate/status", + json={"task_id": "task_123"}, + headers={"X-Locale": "en"}, + ) + + payload = response.get_json()["data"] + assert response.status_code == 200 + assert payload["message"] == "[Planning] Generating the report outline..." + + +def test_generate_status_translates_failed_task_progress_stage(monkeypatch): + app = create_report_test_app() + client = app.test_client() + + monkeypatch.setattr( + report_api.TaskManager, + "get_task", + lambda self, task_id: SimpleNamespace( + to_dict=lambda: { + "task_id": task_id, + "status": "failed", + "progress": -1, + "message": "[failed] 报告生成失败: boom", + } + ), + ) + + response = client.post( + "/api/report/generate/status", + json={"task_id": "task_123"}, + headers={"X-Locale": "en"}, + ) + + payload = response.get_json()["data"] + assert response.status_code == 200 + assert payload["message"] == "[Failed] 报告生成失败: boom" + + +def test_report_progress_message_is_localized(monkeypatch): + app = create_report_test_app() + client = app.test_client() + + monkeypatch.setattr( + report_api.ReportManager, + "get_progress", + lambda report_id: { + "status": "generating", + "progress": 45, + "message": "正在生成章节: Key Findings (1/3)", + }, + ) + + response = client.get( + "/api/report/report_123/progress", + headers={"X-Locale": "en"}, + ) + + payload = response.get_json()["data"] + assert response.status_code == 200 + assert payload["message"] == "Generating section: Key Findings (1/3)" + + +def test_missing_report_errors_are_localized(monkeypatch): + app = create_report_test_app() + client = app.test_client() + + monkeypatch.setattr(report_api.ReportManager, "get_report", lambda report_id: None) + + response = client.get( + "/api/report/report_missing", + headers={"X-Locale": "en"}, + ) + + assert response.status_code == 404 + assert response.get_json()["error"] == "Report not found: report_missing" + + +def test_chat_requires_message_in_english(monkeypatch): + app = create_report_test_app() + client = app.test_client() + + monkeypatch.setattr( + report_api.SimulationManager, + "get_simulation", + lambda self, simulation_id: SimpleNamespace(project_id="proj_123", graph_id="graph_123"), + ) + monkeypatch.setattr( + report_api.ProjectManager, + "get_project", + lambda project_id: SimpleNamespace(graph_id="graph_123", simulation_requirement="Need a report"), + ) + + response = client.post( + "/api/report/chat", + json={"simulation_id": "sim_123"}, + headers={"X-Locale": "en"}, + ) + + assert response.status_code == 400 + assert response.get_json()["error"] == "Please provide message" + + +def test_missing_report_section_error_is_localized(monkeypatch, tmp_path): + app = create_report_test_app() + client = app.test_client() + + missing_path = tmp_path / "section_01.md" + monkeypatch.setattr( + report_api.ReportManager, + "_get_section_path", + lambda report_id, section_index: str(missing_path), + ) + + response = client.get( + "/api/report/report_123/section/1", + headers={"X-Locale": "en"}, + ) + + assert response.status_code == 404 + assert response.get_json()["error"] == "Section not found: section_01.md" + + +def test_generate_status_exception_logs_english_context(monkeypatch): + app = create_report_test_app() + client = app.test_client() + logger = FakeLogger() + + monkeypatch.setattr(report_api, "logger", logger) + + def boom(self, task_id): + raise RuntimeError("status exploded") + + monkeypatch.setattr(report_api.TaskManager, "get_task", boom) + + response = client.post( + "/api/report/generate/status", + json={"task_id": "task_123"}, + headers={"X-Locale": "en"}, + ) + + assert response.status_code == 500 + assert response.get_json()["error"] == "status exploded" + assert logger.errors == ["Failed to query task status: status exploded"] + + +def test_get_report_exception_logs_english_context(monkeypatch): + app = create_report_test_app() + client = app.test_client() + logger = FakeLogger() + + monkeypatch.setattr(report_api, "logger", logger) + + def boom(report_id): + raise RuntimeError("report exploded") + + monkeypatch.setattr(report_api.ReportManager, "get_report", boom) + + response = client.get( + "/api/report/report_123", + headers={"X-Locale": "en"}, + ) + + assert response.status_code == 500 + assert response.get_json()["error"] == "report exploded" + assert logger.errors == ["Failed to fetch the report: report exploded"] + assert logger.debugs + + +def test_generate_report_request_parse_failure_keeps_english_error_context(monkeypatch): + app = create_report_test_app() + client = app.test_client() + logger = FakeLogger() + + monkeypatch.setattr(report_api, "logger", logger) + + original_get_json = Request.get_json + + def boom(self, *args, **kwargs): + if self.path == "/api/report/generate": + raise RuntimeError("bad report json") + return original_get_json(self, *args, **kwargs) + + monkeypatch.setattr(Request, "get_json", boom) + + response = client.post( + "/api/report/generate", + data="{bad json", + content_type="application/json", + headers={"X-Locale": "en"}, + ) + + assert response.status_code == 500 + assert response.get_json()["error"] == "bad report json" + assert logger.errors == ["Failed to start report generation: bad report json"] + assert logger.debugs + + +def test_report_chat_request_parse_failure_keeps_english_error_context(monkeypatch): + app = create_report_test_app() + client = app.test_client() + logger = FakeLogger() + + monkeypatch.setattr(report_api, "logger", logger) + + original_get_json = Request.get_json + + def boom(self, *args, **kwargs): + if self.path == "/api/report/chat": + raise RuntimeError("bad chat json") + return original_get_json(self, *args, **kwargs) + + monkeypatch.setattr(Request, "get_json", boom) + + response = client.post( + "/api/report/chat", + data="{bad json", + content_type="application/json", + headers={"X-Locale": "en"}, + ) + + assert response.status_code == 500 + assert response.get_json()["error"] == "bad chat json" + assert logger.errors == ["Report chat failed: bad chat json"] + assert logger.debugs + + +def test_download_report_uses_verification_friendly_filename(monkeypatch): + app = create_report_test_app() + client = app.test_client() + + report = SimpleNamespace( + report_id="report_123", + simulation_id="sim:456/test", + markdown_content="# test report\n", + ) + with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as handle: + handle.write(report.markdown_content) + temp_path = handle.name + + monkeypatch.setattr(report_api.ReportManager, "get_report", lambda report_id: report) + monkeypatch.setattr(report_api.ReportManager, "_get_report_markdown_path", lambda report_id: temp_path) + + try: + response = client.get("/api/report/report_123/download", headers={"X-Locale": "en"}) + finally: + os.unlink(temp_path) + + assert response.status_code == 200 + assert ( + 'filename=mirofish-report-report_123--simulation-sim-456-test.md' + in response.headers["Content-Disposition"] + ) diff --git a/backend/tests/test_retry_i18n.py b/backend/tests/test_retry_i18n.py new file mode 100644 index 00000000..62dca89f --- /dev/null +++ b/backend/tests/test_retry_i18n.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from unittest.mock import Mock + +import pytest +from flask import Flask + +from app.utils import retry as retry_utils + + +def test_retry_with_backoff_logs_english_messages(monkeypatch): + app = Flask(__name__) + warning = Mock() + error = Mock() + + monkeypatch.setattr(retry_utils.logger, "warning", warning) + monkeypatch.setattr(retry_utils.logger, "error", error) + monkeypatch.setattr(retry_utils.time, "sleep", lambda _: None) + monkeypatch.setattr(retry_utils.random, "random", lambda: 0.0) + + attempts = {"count": 0} + + @retry_utils.retry_with_backoff(max_retries=1, initial_delay=1.0) + def flaky(): + attempts["count"] += 1 + raise ValueError("boom") + + with app.test_request_context(headers={"X-Locale": "en"}): + with pytest.raises(ValueError, match="boom"): + flaky() + + warning.assert_called_once_with( + "Function flaky failed on attempt 1: boom, retrying in 0.5s..." + ) + error.assert_called_once_with( + "Function flaky still failed after 1 retries: boom" + ) + + +@pytest.mark.asyncio +async def test_retry_with_backoff_async_logs_english_messages(monkeypatch): + app = Flask(__name__) + warning = Mock() + error = Mock() + sleep_calls: list[float] = [] + + monkeypatch.setattr(retry_utils.logger, "warning", warning) + monkeypatch.setattr(retry_utils.logger, "error", error) + monkeypatch.setattr(retry_utils.random, "random", lambda: 0.0) + + async def fake_sleep(delay: float) -> None: + sleep_calls.append(delay) + + attempts = {"count": 0} + + @retry_utils.retry_with_backoff_async(max_retries=1, initial_delay=1.0) + async def flaky_async(): + attempts["count"] += 1 + raise RuntimeError("async boom") + + with app.test_request_context(headers={"X-Locale": "en"}): + monkeypatch.setattr("asyncio.sleep", fake_sleep) + with pytest.raises(RuntimeError, match="async boom"): + await flaky_async() + + assert sleep_calls == [0.5] + warning.assert_called_once_with( + "Async function flaky_async failed on attempt 1: async boom, retrying in 0.5s..." + ) + error.assert_called_once_with( + "Async function flaky_async still failed after 1 retries: async boom" + ) + + +def test_retryable_api_client_logs_english_messages(monkeypatch): + app = Flask(__name__) + warning = Mock() + error = Mock() + + monkeypatch.setattr(retry_utils.logger, "warning", warning) + monkeypatch.setattr(retry_utils.logger, "error", error) + monkeypatch.setattr(retry_utils.time, "sleep", lambda _: None) + monkeypatch.setattr(retry_utils.random, "random", lambda: 0.0) + + client = retry_utils.RetryableAPIClient(max_retries=1, initial_delay=1.0) + + attempts = {"count": 0} + + def flaky_call() -> None: + attempts["count"] += 1 + raise LookupError("api boom") + + with app.test_request_context(headers={"X-Locale": "en"}): + with pytest.raises(LookupError, match="api boom"): + client.call_with_retry(flaky_call) + + warning.assert_called_once_with( + "API call failed on attempt 1: api boom, retrying in 0.5s..." + ) + error.assert_called_once_with( + "API call still failed after 1 retries: api boom" + ) + + +def test_retryable_api_client_batch_logs_english_item_failures(monkeypatch): + app = Flask(__name__) + error = Mock() + + monkeypatch.setattr(retry_utils.logger, "error", error) + + client = retry_utils.RetryableAPIClient(max_retries=0) + + def process(item: int) -> int: + if item == 2: + raise ValueError("bad item") + return item * 10 + + with app.test_request_context(headers={"X-Locale": "en"}): + results, failures = client.call_batch_with_retry([1, 2], process) + + assert results == [10] + assert failures == [{"index": 1, "item": 2, "error": "bad item"}] + assert error.call_args_list == [ + (( "API call still failed after 0 retries: bad item",), {}), + (( "Failed to process item 2: bad item",), {}), + ] diff --git a/backend/tests/test_run.py b/backend/tests/test_run.py new file mode 100644 index 00000000..56b0d4c7 --- /dev/null +++ b/backend/tests/test_run.py @@ -0,0 +1,88 @@ +import importlib.util +import sys +from pathlib import Path + +import pytest + + +def load_run_module(): + run_path = Path(__file__).resolve().parents[1] / "run.py" + module_name = "test_run_module" + sys.modules.pop(module_name, None) + sys.modules.pop("app.config", None) + spec = importlib.util.spec_from_file_location(module_name, run_path) + assert spec is not None + assert spec.loader is not None + + run_module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(run_module) + return run_module + + +def test_main_prints_english_startup_validation_output(monkeypatch, capsys): + monkeypatch.setenv("MIROFISH_LOCALE", "en") + run_module = load_run_module() + monkeypatch.setattr( + run_module.Config, + "validate", + lambda locale="zh": ["LLM_API_KEY / OPENAI_API_KEY is not configured"], + ) + + with pytest.raises(SystemExit) as exc: + run_module.main() + + assert exc.value.code == 1 + captured = capsys.readouterr() + assert captured.out == ( + "Configuration errors:\n" + " - LLM_API_KEY / OPENAI_API_KEY is not configured\n" + "\n" + "Check the configuration in the .env file\n" + ) + + +def test_main_defaults_to_chinese_startup_validation_output(monkeypatch, capsys): + monkeypatch.delenv("MIROFISH_LOCALE", raising=False) + run_module = load_run_module() + monkeypatch.setattr( + run_module.Config, + "validate", + lambda locale="zh": ["LLM_API_KEY / OPENAI_API_KEY 未配置"], + ) + + with pytest.raises(SystemExit) as exc: + run_module.main() + + assert exc.value.code == 1 + captured = capsys.readouterr() + assert captured.out == "配置错误:\n - LLM_API_KEY / OPENAI_API_KEY 未配置\n\n请检查 .env 文件中的配置\n" + + +def test_main_accepts_openai_aliases_for_startup_validation(monkeypatch): + monkeypatch.setenv("MIROFISH_LOCALE", "en") + monkeypatch.delenv("LLM_API_KEY", raising=False) + monkeypatch.delenv("LLM_BASE_URL", raising=False) + monkeypatch.delenv("LLM_MODEL_NAME", raising=False) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + monkeypatch.setenv("OPENAI_API_KEY", "codex-test-key") + monkeypatch.setenv("OPENAI_API_BASE_URL", "https://codex.example.test/v1") + monkeypatch.setenv("OPENAI_MODEL", "gpt-4.1-mini") + monkeypatch.setenv("ZEP_API_KEY", "zep-test-key") + + run_module = load_run_module() + seen = {} + + class FakeApp: + def run(self, **kwargs): + seen["run_kwargs"] = kwargs + + monkeypatch.setattr(run_module, "create_app", lambda: FakeApp()) + + run_module.main() + + assert seen["run_kwargs"] == { + "host": "0.0.0.0", + "port": 5001, + "debug": False, + "threaded": True, + } diff --git a/backend/tests/test_simulation_api_i18n.py b/backend/tests/test_simulation_api_i18n.py new file mode 100644 index 00000000..6359d7c0 --- /dev/null +++ b/backend/tests/test_simulation_api_i18n.py @@ -0,0 +1,1078 @@ +from __future__ import annotations + +import csv +import json +import sqlite3 +import sys +import threading +from types import ModuleType + +from flask import Flask + +fake_zep_cloud = ModuleType("zep_cloud") +fake_zep_client = ModuleType("zep_cloud.client") +fake_zep_ontology = ModuleType("zep_cloud.external_clients.ontology") +fake_zep_cloud.client = fake_zep_client +fake_zep_cloud.__getattr__ = lambda name: object + + +class FakeZep: + def __init__(self, *args, **kwargs): + pass + + +fake_zep_client.Zep = FakeZep +fake_zep_ontology.EntityModel = object +fake_zep_ontology.EntityText = object +fake_zep_ontology.EdgeModel = object +sys.modules.setdefault("zep_cloud", fake_zep_cloud) +sys.modules.setdefault("zep_cloud.client", fake_zep_client) +sys.modules.setdefault("zep_cloud.external_clients.ontology", fake_zep_ontology) + +from app.api import simulation_bp +from app.api import simulation as simulation_api +from app.models.project import ProjectManager, ProjectStatus +from app.services.report_agent import Report, ReportManager, ReportStatus +from app.services.simulation_manager import SimulationManager +from app.services.simulation_manager import SimulationState, SimulationStatus +from app.services.simulation_runner import RunnerStatus, SimulationRunState + + +def create_simulation_test_app(): + app = Flask(__name__) + app.register_blueprint(simulation_bp, url_prefix="/api/simulation") + return app + + +class FakeLogger: + def __init__(self): + self.debugs = [] + self.infos = [] + self.warnings = [] + self.errors = [] + + def debug(self, message): + self.debugs.append(message) + + def info(self, message): + self.infos.append(message) + + def warning(self, message): + self.warnings.append(message) + + def error(self, message): + self.errors.append(message) + + +def test_entities_requires_zep_key_in_english(monkeypatch): + app = create_simulation_test_app() + client = app.test_client() + + monkeypatch.setattr(simulation_api.Config, "ZEP_API_KEY", "") + + response = client.get( + "/api/simulation/entities/graph_123", + headers={"X-Locale": "en"}, + ) + + assert response.status_code == 500 + assert response.get_json()["error"] == "ZEP_API_KEY is not configured" + + +def test_entities_request_logs_are_localized(monkeypatch): + app = create_simulation_test_app() + client = app.test_client() + + logger = FakeLogger() + monkeypatch.setattr(simulation_api, "logger", logger) + monkeypatch.setattr(simulation_api.Config, "ZEP_API_KEY", "zep-key") + + class FakeReader: + def filter_defined_entities(self, **kwargs): + assert kwargs == { + "graph_id": "graph_123", + "defined_entity_types": ["Person", "Organization"], + "enrich_with_edges": False, + } + return type("Result", (), {"to_dict": lambda self: {"entities": []}})() + + monkeypatch.setattr(simulation_api, "ZepEntityReader", FakeReader) + + response = client.get( + "/api/simulation/entities/graph_123?entity_types=Person,Organization&enrich=false", + headers={"X-Locale": "en"}, + ) + + assert response.status_code == 200 + assert logger.infos == [ + "Fetching graph entities: graph_id=graph_123, entity_types=['Person', 'Organization'], enrich=False" + ] + + +def test_entity_detail_missing_entity_is_localized(monkeypatch): + app = create_simulation_test_app() + client = app.test_client() + + monkeypatch.setattr(simulation_api.Config, "ZEP_API_KEY", "zep-key") + + class FakeReader: + def get_entity_with_context(self, graph_id, entity_uuid): + assert graph_id == "graph_123" + assert entity_uuid == "entity_404" + return None + + monkeypatch.setattr(simulation_api, "ZepEntityReader", FakeReader) + + response = client.get( + "/api/simulation/entities/graph_123/entity_404", + headers={"X-Locale": "en"}, + ) + + assert response.status_code == 404 + assert response.get_json()["error"] == "Entity not found: entity_404" + + +def test_entities_by_type_requires_zep_key_in_english(monkeypatch): + app = create_simulation_test_app() + client = app.test_client() + + monkeypatch.setattr(simulation_api.Config, "ZEP_API_KEY", "") + + response = client.get( + "/api/simulation/entities/graph_123/by-type/Person", + headers={"X-Locale": "en"}, + ) + + assert response.status_code == 500 + assert response.get_json()["error"] == "ZEP_API_KEY is not configured" + + +def test_generate_profiles_requires_graph_id_in_english(): + app = create_simulation_test_app() + client = app.test_client() + + response = client.post( + "/api/simulation/generate-profiles", + json={}, + headers={"X-Locale": "en"}, + ) + + assert response.status_code == 400 + assert response.get_json()["error"] == "Please provide graph_id" + + +def test_start_requires_simulation_id_in_english(): + app = create_simulation_test_app() + client = app.test_client() + + response = client.post( + "/api/simulation/start", + json={}, + headers={"X-Locale": "en"}, + ) + + assert response.status_code == 400 + assert response.get_json()["error"] == "Please provide simulation_id" + + +def test_stop_requires_simulation_id_in_english(): + app = create_simulation_test_app() + client = app.test_client() + + response = client.post( + "/api/simulation/stop", + json={}, + headers={"X-Locale": "en"}, + ) + + assert response.status_code == 400 + assert response.get_json()["error"] == "Please provide simulation_id" + + +def test_prepare_status_not_started_is_localized(tmp_path, monkeypatch): + app = create_simulation_test_app() + client = app.test_client() + + monkeypatch.setattr(simulation_api.Config, "OASIS_SIMULATION_DATA_DIR", str(tmp_path)) + + response = client.post( + "/api/simulation/prepare/status", + json={"simulation_id": "sim_123"}, + headers={"X-Locale": "en"}, + ) + + assert response.status_code == 200 + payload = response.get_json()["data"] + assert payload["status"] == "not_started" + assert payload["message"] == "Preparation has not started yet. Call /api/simulation/prepare first." + + +def test_prepare_status_translates_task_progress_payload(monkeypatch): + app = create_simulation_test_app() + client = app.test_client() + + class FakeTask: + def to_dict(self): + return { + "task_id": "task_123", + "status": "processing", + "progress": 35, + "message": "[1/4] 读取图谱实体: 正在连接Zep图谱...", + "progress_detail": { + "current_stage": "reading", + "current_stage_name": "读取图谱实体", + "item_description": "正在连接Zep图谱...", + }, + } + + monkeypatch.setattr( + "app.models.task.TaskManager.get_task", + lambda self, task_id: FakeTask(), + ) + + response = client.post( + "/api/simulation/prepare/status", + json={"task_id": "task_123"}, + headers={"X-Locale": "en"}, + ) + + payload = response.get_json()["data"] + assert response.status_code == 200 + assert payload["message"] == "[1/4] Reading graph entities: Connecting to the Zep graph..." + assert payload["progress_detail"]["current_stage_name"] == "Reading graph entities" + assert payload["progress_detail"]["item_description"] == "Connecting to the Zep graph..." + + +def test_delete_history_removes_simulation_reports_and_project_when_last_simulation(tmp_path, monkeypatch): + app = create_simulation_test_app() + client = app.test_client() + + simulation_dir = tmp_path / "simulations" + projects_dir = tmp_path / "projects" + reports_dir = tmp_path / "reports" + projects_dir.mkdir(parents=True, exist_ok=True) + + monkeypatch.setattr(SimulationManager, "SIMULATION_DATA_DIR", str(simulation_dir)) + monkeypatch.setattr(ProjectManager, "PROJECTS_DIR", str(projects_dir)) + monkeypatch.setattr(ReportManager, "REPORTS_DIR", str(reports_dir)) + project = ProjectManager.create_project("Delete me") + project.status = ProjectStatus.GRAPH_COMPLETED + ProjectManager.save_project(project) + + manager = SimulationManager() + state = SimulationState( + simulation_id="sim_delete", + project_id=project.project_id, + graph_id="graph_delete", + status=SimulationStatus.COMPLETED, + ) + manager._save_simulation_state(state) + + ReportManager.save_report( + Report( + report_id="report_delete", + simulation_id=state.simulation_id, + graph_id=state.graph_id, + simulation_requirement="cleanup", + status=ReportStatus.COMPLETED, + markdown_content="# Report", + created_at="2026-03-12T00:00:00", + completed_at="2026-03-12T00:10:00", + ) + ) + + response = client.delete( + "/api/simulation/history/sim_delete", + headers={"X-Locale": "en"}, + ) + payload = response.get_json() + + assert response.status_code == 200 + assert payload["success"] is True + assert payload["message"] == "Deleted simulation record: sim_delete" + assert payload["data"]["project_deleted"] is True + assert payload["data"]["deleted_report_ids"] == ["report_delete"] + assert not (simulation_dir / "sim_delete").exists() + assert not (projects_dir / project.project_id).exists() + assert not (reports_dir / "report_delete").exists() + + +def test_delete_history_keeps_project_when_other_simulations_exist(tmp_path, monkeypatch): + app = create_simulation_test_app() + client = app.test_client() + + simulation_dir = tmp_path / "simulations" + projects_dir = tmp_path / "projects" + reports_dir = tmp_path / "reports" + projects_dir.mkdir(parents=True, exist_ok=True) + + monkeypatch.setattr(SimulationManager, "SIMULATION_DATA_DIR", str(simulation_dir)) + monkeypatch.setattr(ProjectManager, "PROJECTS_DIR", str(projects_dir)) + monkeypatch.setattr(ReportManager, "REPORTS_DIR", str(reports_dir)) + project = ProjectManager.create_project("Shared") + project.status = ProjectStatus.GRAPH_COMPLETED + ProjectManager.save_project(project) + + manager = SimulationManager() + manager._save_simulation_state( + SimulationState( + simulation_id="sim_first", + project_id=project.project_id, + graph_id="graph_shared", + status=SimulationStatus.COMPLETED, + ) + ) + manager._save_simulation_state( + SimulationState( + simulation_id="sim_second", + project_id=project.project_id, + graph_id="graph_shared", + status=SimulationStatus.COMPLETED, + ) + ) + + response = client.delete("/api/simulation/history/sim_first") + payload = response.get_json() + + assert response.status_code == 200 + assert payload["success"] is True + assert payload["data"]["project_deleted"] is False + assert not (simulation_dir / "sim_first").exists() + assert (simulation_dir / "sim_second").exists() + assert (projects_dir / project.project_id).exists() + + +def test_delete_history_rejects_active_simulations(tmp_path, monkeypatch): + app = create_simulation_test_app() + client = app.test_client() + + simulation_dir = tmp_path / "simulations" + projects_dir = tmp_path / "projects" + projects_dir.mkdir(parents=True, exist_ok=True) + + monkeypatch.setattr(SimulationManager, "SIMULATION_DATA_DIR", str(simulation_dir)) + monkeypatch.setattr(ProjectManager, "PROJECTS_DIR", str(projects_dir)) + project = ProjectManager.create_project("Active") + project.status = ProjectStatus.GRAPH_COMPLETED + ProjectManager.save_project(project) + + manager = SimulationManager() + manager._save_simulation_state( + SimulationState( + simulation_id="sim_active", + project_id=project.project_id, + graph_id="graph_active", + status=SimulationStatus.RUNNING, + ) + ) + monkeypatch.setattr( + simulation_api.SimulationRunner, + "get_run_state", + classmethod( + lambda cls, simulation_id: SimulationRunState( + simulation_id=simulation_id, + runner_status=RunnerStatus.RUNNING, + ) + ), + ) + + response = client.delete( + "/api/simulation/history/sim_active", + headers={"X-Locale": "en"}, + ) + + assert response.status_code == 409 + assert response.get_json()["error"] == "Cannot delete simulation while it is still active: sim_active" + assert (simulation_dir / "sim_active").exists() + + +def test_prepare_status_keeps_english_task_progress_payload(monkeypatch): + app = create_simulation_test_app() + client = app.test_client() + + class FakeTask: + def to_dict(self): + return { + "task_id": "task_123", + "status": "processing", + "progress": 35, + "message": "[1/4] Reading graph entities: Connecting to the Zep graph...", + "progress_detail": { + "current_stage": "reading", + "current_stage_name": "Reading graph entities", + "item_description": "Connecting to the Zep graph...", + }, + } + + monkeypatch.setattr( + "app.models.task.TaskManager.get_task", + lambda self, task_id: FakeTask(), + ) + + response = client.post( + "/api/simulation/prepare/status", + json={"task_id": "task_123"}, + headers={"X-Locale": "en"}, + ) + + payload = response.get_json()["data"] + assert response.status_code == 200 + assert payload["message"] == "[1/4] Reading graph entities: Connecting to the Zep graph..." + assert payload["progress_detail"]["current_stage_name"] == "Reading graph entities" + assert payload["progress_detail"]["item_description"] == "Connecting to the Zep graph..." + + +def test_prepare_status_query_failure_logs_are_localized(monkeypatch): + app = create_simulation_test_app() + client = app.test_client() + + logger = FakeLogger() + monkeypatch.setattr(simulation_api, "logger", logger) + monkeypatch.setattr( + "app.models.task.TaskManager.get_task", + lambda self, task_id: (_ for _ in ()).throw(RuntimeError("status exploded")), + ) + + response = client.post( + "/api/simulation/prepare/status", + json={"task_id": "task_123"}, + headers={"X-Locale": "en"}, + ) + + assert response.status_code == 500 + assert logger.errors == ["Failed to query task status: status exploded"] + + +def test_report_lookup_warning_is_localized_in_english(monkeypatch): + logger = FakeLogger() + monkeypatch.setattr(simulation_api, "logger", logger) + monkeypatch.setattr(simulation_api.os.path, "exists", lambda path: True) + monkeypatch.setattr( + simulation_api.os, + "listdir", + lambda path: (_ for _ in ()).throw(RuntimeError("report scan exploded")), + ) + + app = Flask(__name__) + with app.test_request_context(headers={"X-Locale": "en"}): + assert simulation_api._get_report_id_for_simulation("sim_123") is None + + assert logger.warnings == [ + "Failed to find the report for simulation sim_123: report scan exploded" + ] + + +def test_realtime_profiles_read_warning_is_localized(monkeypatch, tmp_path): + app = create_simulation_test_app() + client = app.test_client() + + simulation_id = "sim_profiles_en" + sim_dir = tmp_path / simulation_id + sim_dir.mkdir() + (sim_dir / "reddit_profiles.json").write_text("{", encoding="utf-8") + + monkeypatch.setattr(simulation_api.Config, "OASIS_SIMULATION_DATA_DIR", str(tmp_path)) + logger = FakeLogger() + monkeypatch.setattr(simulation_api, "logger", logger) + + response = client.get( + f"/api/simulation/{simulation_id}/profiles/realtime?platform=reddit", + headers={"X-Locale": "en"}, + ) + + assert response.status_code == 200 + assert logger.warnings == [ + "Failed to read the profiles file (it may still be being written): Expecting property name enclosed in double quotes: line 1 column 2 (char 1)" + ] + + +def test_realtime_config_read_warning_is_localized(monkeypatch, tmp_path): + app = create_simulation_test_app() + client = app.test_client() + + simulation_id = "sim_config_en" + sim_dir = tmp_path / simulation_id + sim_dir.mkdir() + (sim_dir / "simulation_config.json").write_text("{", encoding="utf-8") + + monkeypatch.setattr(simulation_api.Config, "OASIS_SIMULATION_DATA_DIR", str(tmp_path)) + logger = FakeLogger() + monkeypatch.setattr(simulation_api, "logger", logger) + + response = client.get( + f"/api/simulation/{simulation_id}/config/realtime", + headers={"X-Locale": "en"}, + ) + + assert response.status_code == 200 + assert logger.warnings == [ + "Failed to read the config file (it may still be being written): Expecting property name enclosed in double quotes: line 1 column 2 (char 1)" + ] + + +def test_prepare_requires_existing_simulation_in_english(monkeypatch): + app = create_simulation_test_app() + client = app.test_client() + + monkeypatch.setattr(simulation_api.SimulationManager, "get_simulation", lambda self, simulation_id: None) + + response = client.post( + "/api/simulation/prepare", + json={"simulation_id": "sim_missing"}, + headers={"X-Locale": "en"}, + ) + + assert response.status_code == 404 + assert response.get_json()["error"] == "Simulation not found: sim_missing" + + +def test_prepare_request_logs_are_localized_when_already_prepared(monkeypatch): + app = create_simulation_test_app() + client = app.test_client() + + logger = FakeLogger() + monkeypatch.setattr(simulation_api, "logger", logger) + + state = SimulationState( + simulation_id="sim_123", + project_id="proj_123", + graph_id="graph_123", + status=SimulationStatus.CREATED, + ) + monkeypatch.setattr(simulation_api.SimulationManager, "get_simulation", lambda self, simulation_id: state) + monkeypatch.setattr( + simulation_api, + "_check_simulation_prepared", + lambda simulation_id, locale=None: (True, {"status": "ready"}), + ) + + response = client.post( + "/api/simulation/prepare", + json={"simulation_id": "sim_123"}, + headers={"X-Locale": "en"}, + ) + + assert response.status_code == 200 + assert response.get_json()["data"]["already_prepared"] is True + assert logger.infos == [ + "Handling /prepare request: simulation_id=sim_123, force_regenerate=False", + "Simulation sim_123 is already prepared; skipping duplicate generation", + ] + assert logger.debugs == [ + "Checking whether simulation sim_123 is already prepared...", + "Prepare check result: is_prepared=True, prepare_info={'status': 'ready'}", + ] + + +def test_prepare_preview_warning_logs_are_localized(monkeypatch): + app = create_simulation_test_app() + client = app.test_client() + + logger = FakeLogger() + monkeypatch.setattr(simulation_api, "logger", logger) + + state = SimulationState( + simulation_id="sim_123", + project_id="proj_123", + graph_id="graph_123", + status=SimulationStatus.CREATED, + ) + project = type("Project", (), {"simulation_requirement": "predict something"})() + + monkeypatch.setattr(simulation_api.SimulationManager, "get_simulation", lambda self, simulation_id: state) + monkeypatch.setattr(simulation_api, "_check_simulation_prepared", lambda simulation_id, locale=None: (False, {})) + monkeypatch.setattr(simulation_api.ProjectManager, "get_project", lambda project_id: project) + monkeypatch.setattr(simulation_api.ProjectManager, "get_extracted_text", lambda project_id: "context") + + class FakeReader: + def filter_defined_entities(self, **kwargs): + raise RuntimeError("preview exploded") + + monkeypatch.setattr(simulation_api, "ZepEntityReader", FakeReader) + + class FakeThread: + def __init__(self, target, daemon): + self.target = target + self.daemon = daemon + + def start(self): + return None + + monkeypatch.setattr(threading, "Thread", FakeThread) + monkeypatch.setattr( + "app.models.task.TaskManager.create_task", + lambda self, task_type, metadata=None: "task_123", + ) + monkeypatch.setattr(simulation_api.SimulationManager, "_save_simulation_state", lambda self, saved: None) + + response = client.post( + "/api/simulation/prepare", + json={"simulation_id": "sim_123"}, + headers={"X-Locale": "en"}, + ) + + assert response.status_code == 200 + assert logger.infos == [ + "Handling /prepare request: simulation_id=sim_123, force_regenerate=False", + "Simulation sim_123 is not prepared yet; starting the preparation task", + "Preloading entity count synchronously: graph_id=graph_123", + ] + assert logger.warnings == [ + "Failed to preload entity count synchronously; the background task will retry: preview exploded" + ] + + +def test_batch_interview_validation_is_localized(monkeypatch): + app = create_simulation_test_app() + client = app.test_client() + + monkeypatch.setattr(simulation_api.Config, "INTERVIEW_BATCH_TIMEOUT_SECONDS", 300) + + response = client.post( + "/api/simulation/interview/batch", + json={"simulation_id": "sim_123", "interviews": [{}]}, + headers={"X-Locale": "en"}, + ) + + assert response.status_code == 400 + assert response.get_json()["error"] == "Interview item 1 is missing agent_id" + + +def test_env_status_message_is_localized(monkeypatch): + app = create_simulation_test_app() + client = app.test_client() + + monkeypatch.setattr(simulation_api.SimulationRunner, "check_env_alive", lambda simulation_id: True) + monkeypatch.setattr( + simulation_api.SimulationRunner, + "get_env_status_detail", + lambda simulation_id: {"twitter_available": True, "reddit_available": False}, + ) + + response = client.post( + "/api/simulation/env-status", + json={"simulation_id": "sim_123"}, + headers={"X-Locale": "en"}, + ) + + assert response.status_code == 200 + payload = response.get_json()["data"] + assert payload["message"] == "The environment is running and can accept interview commands" + + +def test_close_env_passes_locale_and_returns_localized_message(monkeypatch): + app = create_simulation_test_app() + client = app.test_client() + + def fake_close(simulation_id, timeout, locale=None): + assert simulation_id == "sim_123" + assert timeout == 30 + assert locale == "en" + return {"success": True, "message": "The environment is already closed"} + + monkeypatch.setattr(simulation_api.SimulationRunner, "close_simulation_env", fake_close) + + response = client.post( + "/api/simulation/close-env", + json={"simulation_id": "sim_123"}, + headers={"X-Locale": "en"}, + ) + + assert response.status_code == 200 + payload = response.get_json()["data"] + assert payload["message"] == "The environment is already closed" + + +def test_prepare_check_logs_are_localized_when_state_auto_recovers(tmp_path, monkeypatch): + simulation_dir = tmp_path / "sim_123" + simulation_dir.mkdir() + (simulation_dir / "simulation_config.json").write_text("{}", encoding="utf-8") + (simulation_dir / "reddit_profiles.json").write_text("[]", encoding="utf-8") + (simulation_dir / "twitter_profiles.csv").write_text("username\n", encoding="utf-8") + (simulation_dir / "state.json").write_text( + json.dumps( + { + "status": "preparing", + "config_generated": True, + "entities_count": 2, + "entity_types": ["Person"], + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + + monkeypatch.setattr(simulation_api.Config, "OASIS_SIMULATION_DATA_DIR", str(tmp_path)) + logger = FakeLogger() + monkeypatch.setattr(simulation_api, "logger", logger) + + is_prepared, info = simulation_api._check_simulation_prepared("sim_123", "en") + + assert is_prepared is True + assert info["status"] == "ready" + assert logger.debugs == [ + "Checking simulation prepare state: sim_123, status=preparing, config_generated=True" + ] + assert logger.infos == [ + "Auto-updated simulation state: sim_123 preparing -> ready", + "Simulation sim_123 prepare check result: ready (status=ready, config_generated=True)", + ] + state_data = json.loads((simulation_dir / "state.json").read_text(encoding="utf-8")) + assert state_data["status"] == "ready" + + +def test_start_force_restart_logs_are_localized(monkeypatch): + app = create_simulation_test_app() + client = app.test_client() + + state = SimulationState( + simulation_id="sim_123", + project_id="proj_123", + graph_id="graph_123", + status=SimulationStatus.RUNNING, + ) + + logger = FakeLogger() + monkeypatch.setattr(simulation_api, "logger", logger) + monkeypatch.setattr(simulation_api.SimulationManager, "get_simulation", lambda self, simulation_id: state) + monkeypatch.setattr(simulation_api.SimulationManager, "_save_simulation_state", lambda self, saved: None) + monkeypatch.setattr(simulation_api, "_check_simulation_prepared", lambda simulation_id, locale=None: (True, {})) + + class FakeRunnerStatus: + value = "running" + + class FakeRunState: + runner_status = FakeRunnerStatus() + + def to_dict(self): + return { + "simulation_id": "sim_123", + "runner_status": "running", + "process_pid": 99, + } + + monkeypatch.setattr(simulation_api.SimulationRunner, "get_run_state", lambda simulation_id: FakeRunState()) + monkeypatch.setattr(simulation_api.SimulationRunner, "stop_simulation", lambda simulation_id: None) + monkeypatch.setattr( + simulation_api.SimulationRunner, + "cleanup_simulation_logs", + lambda simulation_id: {"success": False, "errors": ["log still open"]}, + ) + monkeypatch.setattr( + simulation_api.SimulationRunner, + "start_simulation", + lambda **kwargs: FakeRunState(), + ) + + response = client.post( + "/api/simulation/start", + json={"simulation_id": "sim_123", "force": True, "enable_graph_memory_update": True}, + headers={"X-Locale": "en"}, + ) + + assert response.status_code == 200 + payload = response.get_json()["data"] + assert payload["force_restarted"] is True + assert payload["graph_memory_update_enabled"] is True + assert logger.infos == [ + "Force mode: stopping the running simulation sim_123", + "Force mode: cleaning simulation logs for sim_123", + "Simulation sim_123 already has prepared assets; resetting state to ready (previous status: running)", + "Enabling graph-memory updates: simulation_id=sim_123, graph_id=graph_123", + ] + assert logger.warnings == ["Cleaning simulation logs raised a warning: ['log still open']"] + + +def test_interview_endpoint_uses_english_prompt_prefix(monkeypatch): + app = create_simulation_test_app() + client = app.test_client() + + captured = {} + + monkeypatch.setattr(simulation_api.SimulationRunner, "check_env_alive", lambda simulation_id: True) + + def fake_interview_agent(simulation_id, agent_id, prompt, platform, timeout): + captured.update( + simulation_id=simulation_id, + agent_id=agent_id, + prompt=prompt, + platform=platform, + timeout=timeout, + ) + return {"success": True, "response": "ok"} + + monkeypatch.setattr(simulation_api.SimulationRunner, "interview_agent", fake_interview_agent) + + response = client.post( + "/api/simulation/interview", + json={"simulation_id": "sim_123", "agent_id": 7, "prompt": "What changed?", "platform": "twitter"}, + headers={"X-Locale": "en"}, + ) + + assert response.status_code == 200 + assert captured["prompt"].startswith(simulation_api.INTERVIEW_PROMPT_PREFIXES["en"]) + assert captured["prompt"].endswith("What changed?") + + +def test_batch_interview_endpoint_uses_english_prompt_prefix(monkeypatch): + app = create_simulation_test_app() + client = app.test_client() + + captured = {} + + monkeypatch.setattr(simulation_api.Config, "INTERVIEW_BATCH_TIMEOUT_SECONDS", 300) + monkeypatch.setattr(simulation_api.SimulationRunner, "check_env_alive", lambda simulation_id: True) + + def fake_interview_agents_batch(simulation_id, interviews, platform, timeout): + captured.update( + simulation_id=simulation_id, + interviews=interviews, + platform=platform, + timeout=timeout, + ) + return {"success": True, "results": {}} + + monkeypatch.setattr(simulation_api.SimulationRunner, "interview_agents_batch", fake_interview_agents_batch) + + response = client.post( + "/api/simulation/interview/batch", + json={ + "simulation_id": "sim_123", + "interviews": [{"agent_id": 1, "prompt": "How are people reacting?"}], + }, + headers={"X-Locale": "en"}, + ) + + assert response.status_code == 200 + assert captured["interviews"][0]["prompt"].startswith(simulation_api.INTERVIEW_PROMPT_PREFIXES["en"]) + assert captured["interviews"][0]["prompt"].endswith("How are people reacting?") + + +def test_interview_all_endpoint_uses_english_prompt_prefix(monkeypatch): + app = create_simulation_test_app() + client = app.test_client() + + captured = {} + + monkeypatch.setattr(simulation_api.Config, "INTERVIEW_ALL_TIMEOUT_SECONDS", 300) + monkeypatch.setattr(simulation_api.SimulationRunner, "check_env_alive", lambda simulation_id: True) + + def fake_interview_all_agents(simulation_id, prompt, platform, timeout): + captured.update( + simulation_id=simulation_id, + prompt=prompt, + platform=platform, + timeout=timeout, + ) + return {"success": True, "results": {}} + + monkeypatch.setattr(simulation_api.SimulationRunner, "interview_all_agents", fake_interview_all_agents) + + response = client.post( + "/api/simulation/interview/all", + json={"simulation_id": "sim_123", "prompt": "Summarize the mood."}, + headers={"X-Locale": "en"}, + ) + + assert response.status_code == 200 + assert captured["prompt"].startswith(simulation_api.INTERVIEW_PROMPT_PREFIXES["en"]) + assert captured["prompt"].endswith("Summarize the mood.") + + +def test_run_status_exception_logs_english_context(monkeypatch): + app = create_simulation_test_app() + client = app.test_client() + + class FakeLogger: + def __init__(self): + self.errors = [] + self.debugs = [] + + def error(self, message): + self.errors.append(message) + + def debug(self, message): + self.debugs.append(message) + + logger = FakeLogger() + monkeypatch.setattr(simulation_api, "logger", logger) + + def boom(simulation_id): + raise RuntimeError("runner exploded") + + monkeypatch.setattr(simulation_api.SimulationRunner, "get_run_state", boom) + + response = client.get( + "/api/simulation/sim_123/run-status", + headers={"X-Locale": "en"}, + ) + + assert response.status_code == 500 + assert response.get_json()["error"] == "runner exploded" + assert logger.errors == ["Failed to get run status: runner exploded"] + assert logger.debugs + + +def test_posts_missing_database_message_is_localized(monkeypatch, tmp_path): + app = create_simulation_test_app() + client = app.test_client() + + uploads_root = tmp_path / "uploads" + simulations_root = uploads_root / "simulations" + simulations_root.mkdir(parents=True) + + monkeypatch.setattr(simulation_api.Config, "OASIS_SIMULATION_DATA_DIR", str(simulations_root)) + + response = client.get( + "/api/simulation/sim_123/posts?platform=twitter", + headers={"X-Locale": "en"}, + ) + + assert response.status_code == 200 + payload = response.get_json()["data"] + assert payload["count"] == 0 + assert payload["posts"] == [] + assert payload["message"] == ( + "The simulation database does not exist yet. " + "The simulation may not have run for this platform." + ) + + +def test_profiles_realtime_falls_back_to_enabled_twitter_platform(monkeypatch, tmp_path): + app = create_simulation_test_app() + client = app.test_client() + + monkeypatch.setattr(simulation_api.Config, "OASIS_SIMULATION_DATA_DIR", str(tmp_path)) + monkeypatch.setattr(simulation_api.SimulationManager, "SIMULATION_DATA_DIR", str(tmp_path)) + + manager = SimulationManager() + state = manager.create_simulation( + project_id="proj_123", + graph_id="graph_123", + enable_twitter=True, + enable_reddit=False, + ) + + profiles_path = tmp_path / state.simulation_id / "twitter_profiles.csv" + with profiles_path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=["username", "platform"]) + writer.writeheader() + writer.writerow({"username": "tw-user", "platform": "twitter"}) + + response = client.get( + f"/api/simulation/{state.simulation_id}/profiles/realtime?platform=reddit", + headers={"X-Locale": "en"}, + ) + + assert response.status_code == 200 + payload = response.get_json()["data"] + assert payload["platform"] == "twitter" + assert payload["count"] == 1 + assert payload["profiles"] == [{"username": "tw-user", "platform": "twitter"}] + + +def test_posts_infer_enabled_twitter_platform_when_request_omits_platform(monkeypatch, tmp_path): + app = create_simulation_test_app() + client = app.test_client() + + monkeypatch.setattr(simulation_api.Config, "OASIS_SIMULATION_DATA_DIR", str(tmp_path)) + monkeypatch.setattr(simulation_api.SimulationManager, "SIMULATION_DATA_DIR", str(tmp_path)) + + manager = SimulationManager() + state = manager.create_simulation( + project_id="proj_123", + graph_id="graph_123", + enable_twitter=True, + enable_reddit=False, + ) + + db_path = tmp_path / state.simulation_id / "twitter_simulation.db" + conn = sqlite3.connect(db_path) + conn.execute("CREATE TABLE post (id INTEGER PRIMARY KEY, created_at TEXT, content TEXT)") + conn.execute( + "INSERT INTO post (created_at, content) VALUES (?, ?)", + ("2026-03-11T17:40:00", "hello twitter"), + ) + conn.commit() + conn.close() + + response = client.get( + f"/api/simulation/{state.simulation_id}/posts", + headers={"X-Locale": "en"}, + ) + + assert response.status_code == 200 + payload = response.get_json()["data"] + assert payload["platform"] == "twitter" + assert payload["count"] == 1 + assert payload["posts"][0]["content"] == "hello twitter" + + +def test_comments_infer_enabled_twitter_platform_when_request_omits_platform(monkeypatch, tmp_path): + app = create_simulation_test_app() + client = app.test_client() + + monkeypatch.setattr(simulation_api.Config, "OASIS_SIMULATION_DATA_DIR", str(tmp_path)) + monkeypatch.setattr(simulation_api.SimulationManager, "SIMULATION_DATA_DIR", str(tmp_path)) + + manager = SimulationManager() + state = manager.create_simulation( + project_id="proj_123", + graph_id="graph_123", + enable_twitter=True, + enable_reddit=False, + ) + + response = client.get( + f"/api/simulation/{state.simulation_id}/comments", + headers={"X-Locale": "en"}, + ) + + assert response.status_code == 200 + payload = response.get_json()["data"] + assert payload["platform"] == "twitter" + assert payload["count"] == 0 + assert payload["comments"] == [] + + +def test_ready_simulation_run_instructions_are_localized(monkeypatch, tmp_path): + app = create_simulation_test_app() + client = app.test_client() + + monkeypatch.setattr( + simulation_api.SimulationManager, + "SIMULATION_DATA_DIR", + str(tmp_path), + ) + + ready_state = SimulationState( + simulation_id="sim_ready", + project_id="proj_123", + graph_id="graph_123", + status=SimulationStatus.READY, + ) + + monkeypatch.setattr( + simulation_api.SimulationManager, + "get_simulation", + lambda self, simulation_id: ready_state, + ) + + response = client.get( + "/api/simulation/sim_ready", + headers={"X-Locale": "en"}, + ) + + assert response.status_code == 200 + instructions = response.get_json()["data"]["run_instructions"] + assert instructions["commands"]["parallel"].endswith( + "run_parallel_simulation.py --config " + f"{tmp_path}/sim_ready/simulation_config.json" + ) + assert instructions["instructions"] == ( + "1. Activate the conda environment: conda activate MiroFish\n" + f"2. Run the simulation (scripts are located in {instructions['scripts_dir']}):\n" + f" - Run Twitter only: python {instructions['scripts_dir']}/run_twitter_simulation.py --config {tmp_path}/sim_ready/simulation_config.json\n" + f" - Run Reddit only: python {instructions['scripts_dir']}/run_reddit_simulation.py --config {tmp_path}/sim_ready/simulation_config.json\n" + f" - Run both platforms in parallel: python {instructions['scripts_dir']}/run_parallel_simulation.py --config {tmp_path}/sim_ready/simulation_config.json" + ) diff --git a/backend/tests/test_simulation_run_status_detail.py b/backend/tests/test_simulation_run_status_detail.py new file mode 100644 index 00000000..36e4922c --- /dev/null +++ b/backend/tests/test_simulation_run_status_detail.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from app.api import simulation as simulation_api +from app.services.simulation_runner import AgentAction, RunnerStatus, SimulationRunState + +from test_simulation_api_i18n import create_simulation_test_app + + +def test_run_status_detail_caps_recent_actions(monkeypatch): + app = create_simulation_test_app() + client = app.test_client() + + run_state = SimulationRunState( + simulation_id="sim_123", + runner_status=RunnerStatus.RUNNING, + current_round=3, + total_rounds=12, + ) + + all_actions = [ + AgentAction( + round_num=3, + timestamp="2026-03-11T14:00:00", + platform="twitter", + agent_id=1, + agent_name="Alice", + action_type="CREATE_POST", + ) + ] + recent_actions = [ + AgentAction( + round_num=3, + timestamp=f"2026-03-11T14:00:{index:02d}", + platform="reddit", + agent_id=index, + agent_name=f"Agent {index}", + action_type="CREATE_COMMENT", + ) + for index in range(250) + ] + + monkeypatch.setattr(simulation_api.SimulationRunner, "get_run_state", lambda simulation_id: run_state) + monkeypatch.setattr(simulation_api.SimulationRunner, "get_all_actions", lambda **kwargs: all_actions) + monkeypatch.setattr(simulation_api.SimulationRunner, "get_actions", lambda **kwargs: recent_actions[: kwargs["limit"]]) + + response = client.get("/api/simulation/sim_123/run-status/detail") + + assert response.status_code == 200 + payload = response.get_json()["data"] + assert payload["returned_actions_count"] == 1 + assert len(payload["all_actions"]) == 1 + assert len(payload["recent_actions"]) == simulation_api.RUN_STATUS_DETAIL_RECENT_ACTIONS_LIMIT + + +def test_run_status_detail_exposes_waiting_diagnostics(monkeypatch): + app = create_simulation_test_app() + client = app.test_client() + + run_state = SimulationRunState( + simulation_id="sim_waiting", + runner_status=RunnerStatus.RUNNING, + current_round=0, + total_rounds=12, + process_pid=31337, + ) + + monkeypatch.setattr(simulation_api.SimulationRunner, "get_run_state", lambda simulation_id: run_state) + monkeypatch.setattr(simulation_api.SimulationRunner, "get_all_actions", lambda **kwargs: []) + monkeypatch.setattr(simulation_api.SimulationRunner, "get_actions", lambda **kwargs: []) + monkeypatch.setattr(simulation_api.SimulationRunner, "_process_pid_is_alive", lambda process_pid: True) + monkeypatch.setattr( + simulation_api.SimulationRunner, + "get_simulation_log_tail", + lambda simulation_id: "booting simulation runtime\nloading agents", + ) + + response = client.get("/api/simulation/sim_waiting/run-status/detail") + + assert response.status_code == 200 + payload = response.get_json()["data"] + diagnostics = payload["waiting_diagnostics"] + assert diagnostics["waiting_for_actions"] is True + assert diagnostics["process_alive"] is True + assert diagnostics["process_pid"] == 31337 + assert diagnostics["latest_action_timestamp"] is None + assert diagnostics["simulation_log_tail"] == "booting simulation runtime\nloading agents" diff --git a/backend/tests/test_simulation_runner_actions.py b/backend/tests/test_simulation_runner_actions.py new file mode 100644 index 00000000..db061fe1 --- /dev/null +++ b/backend/tests/test_simulation_runner_actions.py @@ -0,0 +1,759 @@ +import importlib +import json +import sys +import types +from pathlib import Path +from unittest import mock + + +def _load_simulation_runner_module(): + if "zep_cloud" not in sys.modules: + zep_cloud = types.ModuleType("zep_cloud") + zep_cloud_client = types.ModuleType("zep_cloud.client") + zep_cloud.EpisodeData = type("EpisodeData", (), {}) + zep_cloud.EntityEdgeSourceTarget = type("EntityEdgeSourceTarget", (), {}) + zep_cloud.InternalServerError = type("InternalServerError", (Exception,), {}) + zep_cloud.NotFoundError = type("NotFoundError", (Exception,), {}) + + def _make_placeholder(name): + return type(name, (), {}) + + def _missing_attr(name): + value = _make_placeholder(name) + setattr(zep_cloud, name, value) + return value + + zep_cloud.__getattr__ = _missing_attr + zep_cloud_client.Zep = object + zep_cloud.client = zep_cloud_client + sys.modules["zep_cloud"] = zep_cloud + sys.modules["zep_cloud.client"] = zep_cloud_client + + sys.modules.pop("app.services.simulation_runner", None) + return importlib.import_module("app.services.simulation_runner") + + +def _write_actions(path, actions): + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as handle: + for action in actions: + handle.write(json.dumps(action, ensure_ascii=False) + "\n") + + +class _FakeLogger: + def __init__(self): + self.info_messages = [] + self.warning_messages = [] + self.error_messages = [] + + def info(self, message): + self.info_messages.append(message) + + def warning(self, message): + self.warning_messages.append(message) + + def error(self, message): + self.error_messages.append(message) + + +def test_get_all_actions_supports_incremental_windows(tmp_path): + module = _load_simulation_runner_module() + runner = module.SimulationRunner + original_run_state_dir = runner.RUN_STATE_DIR + runner.RUN_STATE_DIR = str(tmp_path) + + try: + simulation_dir = tmp_path / "sim-memory" + _write_actions( + simulation_dir / "twitter" / "actions.jsonl", + [ + { + "round": 1, + "timestamp": "2026-03-11T08:00:00", + "agent_id": 1, + "agent_name": "Alice", + "action_type": "CREATE_POST", + "action_args": {"content": "first"}, + "success": True, + }, + { + "round": 2, + "timestamp": "2026-03-11T08:00:02", + "agent_id": 1, + "agent_name": "Alice", + "action_type": "CREATE_POST", + "action_args": {"content": "second"}, + "success": True, + }, + ], + ) + _write_actions( + simulation_dir / "reddit" / "actions.jsonl", + [ + { + "round": 2, + "timestamp": "2026-03-11T08:00:01", + "agent_id": 7, + "agent_name": "Bob", + "action_type": "CREATE_COMMENT", + "action_args": {"content": "reply"}, + "success": True, + } + ], + ) + + latest_two = runner.get_all_actions("sim-memory", limit=2) + assert [action.timestamp for action in latest_two] == [ + "2026-03-11T08:00:02", + "2026-03-11T08:00:01", + ] + + incremental = runner.get_all_actions( + "sim-memory", + since_timestamp="2026-03-11T08:00:01", + ) + assert [action.timestamp for action in incremental] == [ + "2026-03-11T08:00:02", + "2026-03-11T08:00:01", + ] + assert [action.platform for action in incremental] == ["twitter", "reddit"] + finally: + runner.RUN_STATE_DIR = original_run_state_dir + + +def test_start_simulation_fails_fast_when_optional_runtime_is_missing(tmp_path): + module = _load_simulation_runner_module() + runner = module.SimulationRunner + original_run_state_dir = runner.RUN_STATE_DIR + original_scripts_dir = runner.SCRIPTS_DIR + runner.RUN_STATE_DIR = str(tmp_path) + runner.SCRIPTS_DIR = str(tmp_path) + + simulation_dir = tmp_path / "sim-missing-runtime" + simulation_dir.mkdir() + (simulation_dir / "simulation_config.json").write_text( + json.dumps({"time_config": {"total_simulation_hours": 1, "minutes_per_round": 30}}), + encoding="utf-8", + ) + (tmp_path / "run_parallel_simulation.py").write_text("# placeholder", encoding="utf-8") + + try: + with mock.patch.object(runner, "_simulation_dependencies_available", return_value=False): + try: + runner.start_simulation("sim-missing-runtime") + except RuntimeError as exc: + assert str(exc) == module.SIMULATION_DEPENDENCY_ERROR + else: + raise AssertionError("expected RuntimeError when optional simulation runtime is missing") + finally: + runner.RUN_STATE_DIR = original_run_state_dir + runner.SCRIPTS_DIR = original_scripts_dir + + +def test_start_simulation_can_render_english_dependency_error(tmp_path): + module = _load_simulation_runner_module() + runner = module.SimulationRunner + original_run_state_dir = runner.RUN_STATE_DIR + original_scripts_dir = runner.SCRIPTS_DIR + runner.RUN_STATE_DIR = str(tmp_path) + runner.SCRIPTS_DIR = str(tmp_path) + + simulation_dir = tmp_path / "sim-missing-runtime-en" + simulation_dir.mkdir() + (simulation_dir / "simulation_config.json").write_text( + json.dumps({"time_config": {"total_simulation_hours": 1, "minutes_per_round": 30}}), + encoding="utf-8", + ) + (tmp_path / "run_parallel_simulation.py").write_text("# placeholder", encoding="utf-8") + + try: + with mock.patch.object(runner, "_simulation_dependencies_available", return_value=False): + try: + runner.start_simulation("sim-missing-runtime-en", locale="en") + except RuntimeError as exc: + assert ( + str(exc) + == "The optional OASIS simulation runtime dependencies are not installed. Run `npm run setup:backend:simulation`, or `uv sync --extra simulation` inside the backend directory first." + ) + else: + raise AssertionError("expected RuntimeError when optional simulation runtime is missing") + finally: + runner.RUN_STATE_DIR = original_run_state_dir + runner.SCRIPTS_DIR = original_scripts_dir + + +def test_start_simulation_passes_locale_to_runner_process(tmp_path): + module = _load_simulation_runner_module() + runner = module.SimulationRunner + original_run_state_dir = runner.RUN_STATE_DIR + original_scripts_dir = runner.SCRIPTS_DIR + original_processes = runner._processes.copy() + original_threads = runner._monitor_threads.copy() + original_stdout_files = runner._stdout_files.copy() + original_stderr_files = runner._stderr_files.copy() + runner.RUN_STATE_DIR = str(tmp_path) + runner.SCRIPTS_DIR = str(tmp_path) + + simulation_dir = tmp_path / "sim-locale" + simulation_dir.mkdir() + (simulation_dir / "simulation_config.json").write_text( + json.dumps({"time_config": {"total_simulation_hours": 1, "minutes_per_round": 30}}), + encoding="utf-8", + ) + (tmp_path / "run_parallel_simulation.py").write_text("# placeholder", encoding="utf-8") + + popen_calls = {} + + class FakeProcess: + pid = 4242 + + def poll(self): + return None + + class FakeThread: + def __init__(self, target=None, args=None, daemon=None): + self.target = target + self.args = args + self.daemon = daemon + + def start(self): + return None + + def fake_popen(cmd, cwd, stdout, stderr, text, encoding, bufsize, env, start_new_session): + popen_calls["env"] = env + return FakeProcess() + + try: + with mock.patch.object(runner, "_simulation_dependencies_available", return_value=True): + with mock.patch.object(module.subprocess, "Popen", side_effect=fake_popen): + with mock.patch.object(module.threading, "Thread", FakeThread): + state = runner.start_simulation("sim-locale", locale="en") + + assert state.locale == "en" + assert popen_calls["env"]["MIROFISH_LOCALE"] == "en" + finally: + for handle in runner._stdout_files.values(): + try: + handle.close() + except Exception: + pass + runner.RUN_STATE_DIR = original_run_state_dir + runner.SCRIPTS_DIR = original_scripts_dir + runner._processes = original_processes + runner._monitor_threads = original_threads + runner._stdout_files = original_stdout_files + runner._stderr_files = original_stderr_files + + +def test_start_simulation_passes_locale_to_graph_memory_updater(tmp_path): + module = _load_simulation_runner_module() + runner = module.SimulationRunner + original_run_state_dir = runner.RUN_STATE_DIR + original_scripts_dir = runner.SCRIPTS_DIR + original_processes = runner._processes.copy() + original_threads = runner._monitor_threads.copy() + original_stdout_files = runner._stdout_files.copy() + original_stderr_files = runner._stderr_files.copy() + original_graph_memory_enabled = runner._graph_memory_enabled.copy() + runner.RUN_STATE_DIR = str(tmp_path) + runner.SCRIPTS_DIR = str(tmp_path) + + simulation_dir = tmp_path / "sim-graph-memory-locale" + simulation_dir.mkdir() + (simulation_dir / "simulation_config.json").write_text( + json.dumps({"time_config": {"total_simulation_hours": 1, "minutes_per_round": 30}}), + encoding="utf-8", + ) + (tmp_path / "run_parallel_simulation.py").write_text("# placeholder", encoding="utf-8") + + class FakeProcess: + pid = 4343 + + def poll(self): + return None + + class FakeThread: + def __init__(self, target=None, args=None, daemon=None): + self.target = target + self.args = args + self.daemon = daemon + + def start(self): + return None + + updater_calls = {} + + def fake_popen(cmd, cwd, stdout, stderr, text, encoding, bufsize, env, start_new_session): + return FakeProcess() + + def fake_create_updater(simulation_id, graph_id, locale="zh"): + updater_calls["simulation_id"] = simulation_id + updater_calls["graph_id"] = graph_id + updater_calls["locale"] = locale + return object() + + try: + with mock.patch.object(runner, "_simulation_dependencies_available", return_value=True): + with mock.patch.object(module.subprocess, "Popen", side_effect=fake_popen): + with mock.patch.object(module.threading, "Thread", FakeThread): + with mock.patch.object(module.ZepGraphMemoryManager, "create_updater", side_effect=fake_create_updater): + state = runner.start_simulation( + "sim-graph-memory-locale", + locale="en", + enable_graph_memory_update=True, + graph_id="graph_123", + ) + + assert state.locale == "en" + assert updater_calls == { + "simulation_id": "sim-graph-memory-locale", + "graph_id": "graph_123", + "locale": "en", + } + finally: + for handle in runner._stdout_files.values(): + try: + handle.close() + except Exception: + pass + runner.RUN_STATE_DIR = original_run_state_dir + runner.SCRIPTS_DIR = original_scripts_dir + runner._processes = original_processes + runner._monitor_threads = original_threads + runner._stdout_files = original_stdout_files + runner._stderr_files = original_stderr_files + runner._graph_memory_enabled = original_graph_memory_enabled + + +def test_simulation_runtime_manifests_do_not_depend_on_camel_oasis(): + backend_dir = Path(__file__).resolve().parents[1] + pyproject = (backend_dir / "pyproject.toml").read_text(encoding="utf-8") + requirements = (backend_dir / "requirements-simulation.txt").read_text(encoding="utf-8") + + assert "camel-oasis" not in pyproject + assert "camel-oasis" not in requirements + assert "unstructured" not in requirements + assert (backend_dir / "oasis" / "__init__.py").exists() + + +def test_format_process_exit_error_classifies_huggingface_network_failures(): + module = _load_simulation_runner_module() + runner = module.SimulationRunner + + error = runner._format_process_exit_error( + exit_code=1, + details=( + "requests.exceptions.ProxyError: HTTPSConnectionPool(host='huggingface.co', port=443): " + "Max retries exceeded while calling hf_hub_download for sentence-transformers/all-MiniLM-L6-v2" + ), + ) + + assert "HuggingFace" in error + assert "huggingface.co" in error + + +def test_format_process_exit_error_localizes_huggingface_network_failures_in_english(): + module = _load_simulation_runner_module() + runner = module.SimulationRunner + + error = runner._format_process_exit_error( + exit_code=1, + details=( + "urllib3.exceptions.ConnectTimeoutError: HTTPSConnectionPool(host='huggingface.co', port=443) " + "timed out while loading transformers model" + ), + locale="en", + ) + + assert ( + error + == "The simulation run failed while downloading HuggingFace models or assets. " + "Check that this machine can reach huggingface.co, then verify your proxy/VPN settings and retry." + ) + + +def test_format_process_exit_error_keeps_generic_message_for_other_failures(): + module = _load_simulation_runner_module() + runner = module.SimulationRunner + + error = runner._format_process_exit_error( + exit_code=2, + details="ValueError: invalid simulation config", + locale="en", + ) + + assert error == "Process exited with code 2. Error: ValueError: invalid simulation config" + + +def test_cleanup_simulation_logs_can_render_english_missing_directory_message(tmp_path): + module = _load_simulation_runner_module() + runner = module.SimulationRunner + original_run_state_dir = runner.RUN_STATE_DIR + runner.RUN_STATE_DIR = str(tmp_path) + + try: + result = runner.cleanup_simulation_logs("sim-cleanup-missing", locale="en") + assert result == { + "success": True, + "message": "The simulation directory does not exist and does not need cleanup", + } + finally: + runner.RUN_STATE_DIR = original_run_state_dir + + +def test_cleanup_simulation_logs_localizes_delete_failures_in_english(tmp_path): + module = _load_simulation_runner_module() + runner = module.SimulationRunner + original_run_state_dir = runner.RUN_STATE_DIR + runner.RUN_STATE_DIR = str(tmp_path) + + simulation_dir = tmp_path / "sim-cleanup-errors" + simulation_dir.mkdir() + target_file = simulation_dir / "run_state.json" + target_file.write_text("{}", encoding="utf-8") + + real_remove = module.os.remove + + def fake_remove(path): + if Path(path) == target_file: + raise PermissionError("permission denied") + return real_remove(path) + + try: + with mock.patch.object(module.os, "remove", side_effect=fake_remove): + result = runner.cleanup_simulation_logs("sim-cleanup-errors", locale="en") + + assert result["success"] is False + assert result["errors"] == [ + "Failed to delete run_state.json: permission denied", + ] + finally: + runner.RUN_STATE_DIR = original_run_state_dir + + +def test_cleanup_all_simulations_persists_english_shutdown_error(tmp_path): + module = _load_simulation_runner_module() + runner = module.SimulationRunner + original_run_state_dir = runner.RUN_STATE_DIR + original_processes = runner._processes.copy() + original_action_queues = runner._action_queues.copy() + original_stdout_files = runner._stdout_files.copy() + original_stderr_files = runner._stderr_files.copy() + original_graph_memory_enabled = runner._graph_memory_enabled.copy() + original_cleanup_done = runner._cleanup_done + runner.RUN_STATE_DIR = str(tmp_path) + runner._processes = {} + runner._action_queues = {} + runner._stdout_files = {} + runner._stderr_files = {} + runner._graph_memory_enabled = {} + runner._cleanup_done = False + + state = module.SimulationRunState( + simulation_id="sim-shutdown-en", + locale="en", + runner_status=module.RunnerStatus.RUNNING, + twitter_running=True, + reddit_running=True, + ) + runner._save_run_state(state) + + class FakeProcess: + pid = 5150 + + def poll(self): + return None + + runner._processes["sim-shutdown-en"] = FakeProcess() + + try: + with mock.patch.object(runner, "_terminate_process", return_value=None): + with mock.patch.object(module.ZepGraphMemoryManager, "stop_all", return_value=None): + runner.cleanup_all_simulations() + + updated = runner.get_run_state("sim-shutdown-en") + assert updated is not None + assert updated.runner_status == module.RunnerStatus.STOPPED + assert updated.error == "The server is shutting down, so the simulation was stopped" + finally: + runner.RUN_STATE_DIR = original_run_state_dir + runner._processes = original_processes + runner._action_queues = original_action_queues + runner._stdout_files = original_stdout_files + runner._stderr_files = original_stderr_files + runner._graph_memory_enabled = original_graph_memory_enabled + runner._cleanup_done = original_cleanup_done + + +def test_check_env_alive_rejects_stale_alive_status_when_process_is_gone(tmp_path): + module = _load_simulation_runner_module() + runner = module.SimulationRunner + original_run_state_dir = runner.RUN_STATE_DIR + original_states = runner._run_states.copy() + runner.RUN_STATE_DIR = str(tmp_path) + runner._run_states = {} + + simulation_dir = tmp_path / "sim-stale-env" + simulation_dir.mkdir() + (simulation_dir / "env_status.json").write_text( + json.dumps( + { + "status": "alive", + "twitter_available": True, + "reddit_available": True, + "timestamp": "2026-03-11T16:00:00", + } + ), + encoding="utf-8", + ) + runner._save_run_state( + module.SimulationRunState( + simulation_id="sim-stale-env", + locale="en", + runner_status=module.RunnerStatus.RUNNING, + process_pid=424242, + ) + ) + + try: + with mock.patch.object(runner, "_process_pid_is_alive", return_value=False): + assert runner.check_env_alive("sim-stale-env") is False + + updated = runner.get_run_state("sim-stale-env") + assert updated is not None + assert updated.runner_status == module.RunnerStatus.STOPPED + assert updated.error == module.tr("simulation.environment_not_alive", "en") + + env_status = json.loads((simulation_dir / "env_status.json").read_text(encoding="utf-8")) + assert env_status["status"] == "stopped" + finally: + runner.RUN_STATE_DIR = original_run_state_dir + runner._run_states = original_states + + +def test_check_env_alive_rejects_non_running_run_state_even_with_alive_file(tmp_path): + module = _load_simulation_runner_module() + runner = module.SimulationRunner + original_run_state_dir = runner.RUN_STATE_DIR + original_states = runner._run_states.copy() + runner.RUN_STATE_DIR = str(tmp_path) + runner._run_states = {} + + simulation_dir = tmp_path / "sim-completed-env" + simulation_dir.mkdir() + (simulation_dir / "env_status.json").write_text( + json.dumps( + { + "status": "alive", + "twitter_available": False, + "reddit_available": True, + "timestamp": "2026-03-11T16:00:00", + } + ), + encoding="utf-8", + ) + runner._save_run_state( + module.SimulationRunState( + simulation_id="sim-completed-env", + locale="zh", + runner_status=module.RunnerStatus.COMPLETED, + process_pid=999, + ) + ) + + try: + assert runner.check_env_alive("sim-completed-env") is False + + env_status = json.loads((simulation_dir / "env_status.json").read_text(encoding="utf-8")) + assert env_status["status"] == "stopped" + finally: + runner.RUN_STATE_DIR = original_run_state_dir + runner._run_states = original_states + + +def test_get_run_state_reconciles_stale_running_state_when_process_is_gone(tmp_path): + module = _load_simulation_runner_module() + runner = module.SimulationRunner + original_run_state_dir = runner.RUN_STATE_DIR + original_states = runner._run_states.copy() + runner.RUN_STATE_DIR = str(tmp_path) + runner._run_states = {} + + try: + runner._save_run_state( + module.SimulationRunState( + simulation_id="sim-stale-runner", + locale="en", + runner_status=module.RunnerStatus.RUNNING, + process_pid=515151, + ) + ) + + with mock.patch.object(runner, "_process_pid_is_alive", return_value=False): + state = runner.get_run_state("sim-stale-runner") + + assert state is not None + assert state.runner_status == module.RunnerStatus.STOPPED + assert state.error == module.tr("simulation.environment_not_alive", "en") + assert state.completed_at is not None + assert state.twitter_running is False + assert state.reddit_running is False + finally: + runner.RUN_STATE_DIR = original_run_state_dir + runner._run_states = original_states + + +def test_read_action_log_localizes_platform_completion_messages_in_english(tmp_path): + module = _load_simulation_runner_module() + runner = module.SimulationRunner + original_run_state_dir = runner.RUN_STATE_DIR + runner.RUN_STATE_DIR = str(tmp_path) + + simulation_id = "sim-platform-finish-en" + simulation_dir = tmp_path / simulation_id + twitter_log = simulation_dir / "twitter" / "actions.jsonl" + reddit_log = simulation_dir / "reddit" / "actions.jsonl" + _write_actions( + twitter_log, + [ + { + "event_type": "simulation_end", + "total_rounds": 3, + "total_actions": 9, + } + ], + ) + _write_actions( + reddit_log, + [ + { + "event_type": "simulation_end", + "total_rounds": 3, + "total_actions": 7, + } + ], + ) + state = module.SimulationRunState( + simulation_id=simulation_id, + locale="en", + runner_status=module.RunnerStatus.RUNNING, + twitter_running=True, + reddit_running=True, + ) + fake_logger = _FakeLogger() + + try: + with mock.patch.object(module, "logger", fake_logger): + twitter_position = runner._read_action_log(str(twitter_log), 0, state, "twitter") + reddit_position = runner._read_action_log(str(reddit_log), 0, state, "reddit") + + assert twitter_position > 0 + assert reddit_position > 0 + assert "twitter simulation completed: sim-platform-finish-en, total_rounds=3, total_actions=9" in fake_logger.info_messages + assert "reddit simulation completed: sim-platform-finish-en, total_rounds=3, total_actions=7" in fake_logger.info_messages + assert "All platform simulations completed: sim-platform-finish-en" in fake_logger.info_messages + finally: + runner.RUN_STATE_DIR = original_run_state_dir + + +def test_stop_simulation_logs_english_stop_message(tmp_path): + module = _load_simulation_runner_module() + runner = module.SimulationRunner + original_run_state_dir = runner.RUN_STATE_DIR + original_processes = runner._processes.copy() + original_states = runner._run_states.copy() + runner.RUN_STATE_DIR = str(tmp_path) + runner._run_states = {} + runner._processes = {} + + state = module.SimulationRunState( + simulation_id="sim-stop-en", + locale="en", + runner_status=module.RunnerStatus.RUNNING, + twitter_running=True, + ) + runner._save_run_state(state) + + class FakeProcess: + pid = 7070 + + def poll(self): + return None + + runner._processes["sim-stop-en"] = FakeProcess() + fake_logger = _FakeLogger() + + try: + with mock.patch.object(module, "logger", fake_logger): + with mock.patch.object(runner, "_terminate_process", return_value=None): + updated = runner.stop_simulation("sim-stop-en") + + assert updated.runner_status == module.RunnerStatus.STOPPED + assert "Simulation stopped: sim-stop-en" in fake_logger.info_messages + finally: + runner.RUN_STATE_DIR = original_run_state_dir + runner._run_states = original_states + runner._processes = original_processes + + +def test_cleanup_all_simulations_logs_english_lifecycle_messages(tmp_path): + module = _load_simulation_runner_module() + runner = module.SimulationRunner + original_run_state_dir = runner.RUN_STATE_DIR + original_states = runner._run_states.copy() + original_processes = runner._processes.copy() + original_action_queues = runner._action_queues.copy() + original_stdout_files = runner._stdout_files.copy() + original_stderr_files = runner._stderr_files.copy() + original_graph_memory_enabled = runner._graph_memory_enabled.copy() + original_cleanup_done = runner._cleanup_done + runner.RUN_STATE_DIR = str(tmp_path) + runner._run_states = {} + runner._processes = {} + runner._action_queues = {} + runner._stdout_files = {} + runner._stderr_files = {} + runner._graph_memory_enabled = {} + runner._cleanup_done = False + + simulation_id = "sim-cleanup-en" + simulation_dir = tmp_path / simulation_id + simulation_dir.mkdir() + (simulation_dir / "state.json").write_text(json.dumps({"status": "running"}), encoding="utf-8") + runner._save_run_state( + module.SimulationRunState( + simulation_id=simulation_id, + locale="en", + runner_status=module.RunnerStatus.RUNNING, + twitter_running=True, + ) + ) + + class FakeProcess: + pid = 8181 + + def poll(self): + return None + + runner._processes[simulation_id] = FakeProcess() + fake_logger = _FakeLogger() + + try: + with mock.patch.object(module, "logger", fake_logger): + with mock.patch.object(runner, "_terminate_process", return_value=None): + with mock.patch.object(module.ZepGraphMemoryManager, "stop_all", return_value=None): + runner.cleanup_all_simulations() + + assert "Cleaning up all simulation processes..." in fake_logger.info_messages + assert f"Terminating simulation process: {simulation_id}, pid=8181" in fake_logger.info_messages + assert f"Updated state.json status to stopped: {simulation_id}" in fake_logger.info_messages + assert "Simulation process cleanup completed" in fake_logger.info_messages + finally: + runner.RUN_STATE_DIR = original_run_state_dir + runner._run_states = original_states + runner._processes = original_processes + runner._action_queues = original_action_queues + runner._stdout_files = original_stdout_files + runner._stderr_files = original_stderr_files + runner._graph_memory_enabled = original_graph_memory_enabled + runner._cleanup_done = original_cleanup_done diff --git a/backend/tests/test_simulation_service_i18n.py b/backend/tests/test_simulation_service_i18n.py new file mode 100644 index 00000000..c9513aaf --- /dev/null +++ b/backend/tests/test_simulation_service_i18n.py @@ -0,0 +1,608 @@ +from __future__ import annotations + +import builtins +import csv +import json +from types import SimpleNamespace + +from flask import Flask + +from app.services import simulation_ipc as simulation_ipc_module +from app.services.simulation_ipc import CommandType, SimulationIPCClient +from app.services.simulation_manager import SimulationManager, SimulationStatus +from app.services.simulation_runner import RunnerStatus, SimulationRunState, SimulationRunner +from app.utils.file_parser import FileParser + + +def test_file_parser_pdf_dependency_uses_english_request_locale(tmp_path, monkeypatch): + pdf_path = tmp_path / "sample.pdf" + pdf_path.write_bytes(b"%PDF-1.4\n") + + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "fitz": + raise ImportError("fitz missing") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + + app = Flask(__name__) + with app.test_request_context(headers={"X-Locale": "en"}): + try: + FileParser.extract_text(str(pdf_path)) + except ImportError as exc: + assert str(exc) == "Missing PDF parsing dependency PyMuPDF. Install it with `pip install PyMuPDF` first." + else: + raise AssertionError("expected ImportError for missing fitz") + + +def test_simulation_ipc_timeout_uses_english_request_locale(tmp_path): + app = Flask(__name__) + client = SimulationIPCClient(str(tmp_path)) + + with app.test_request_context(headers={"X-Locale": "en"}): + try: + client.send_command(CommandType.CLOSE_ENV, {}, timeout=0.01, poll_interval=0.0) + except TimeoutError as exc: + assert str(exc) == "Timed out while waiting for the command response (0.01s)" + else: + raise AssertionError("expected TimeoutError when no IPC response arrives") + + +def test_simulation_ipc_timeout_uses_explicit_locale_without_request_context(tmp_path): + client = SimulationIPCClient(str(tmp_path), locale="en") + + try: + client.send_command(CommandType.CLOSE_ENV, {}, timeout=0.01, poll_interval=0.0) + except TimeoutError as exc: + assert str(exc) == "Timed out while waiting for the command response (0.01s)" + else: + raise AssertionError("expected TimeoutError when no IPC response arrives") + + +def test_simulation_ipc_logs_english_timeout_diagnostics(tmp_path, monkeypatch): + app = Flask(__name__) + fake_logger = SimpleNamespace(info=lambda *_: None, warning=lambda *_: None, error_messages=[]) + + def _capture_error(message): + fake_logger.error_messages.append(message) + + fake_logger.error = _capture_error + monkeypatch.setattr(simulation_ipc_module, "logger", fake_logger) + + with app.test_request_context(headers={"X-Locale": "en"}): + client = SimulationIPCClient(str(tmp_path)) + try: + client.send_command(CommandType.CLOSE_ENV, {}, timeout=0.01, poll_interval=0.0) + except TimeoutError: + pass + else: + raise AssertionError("expected TimeoutError when no IPC response arrives") + + assert fake_logger.error_messages[-1] == "Timed out while waiting for the command response (0.01s)" + + +def test_simulation_ipc_logs_english_send_and_receive_messages(tmp_path, monkeypatch): + app = Flask(__name__) + + class FakeLogger: + def __init__(self): + self.info_messages = [] + + def info(self, message): + self.info_messages.append(message) + + def warning(self, message): + raise AssertionError(f"did not expect warning log: {message}") + + def error(self, message): + raise AssertionError(f"did not expect error log: {message}") + + fake_logger = FakeLogger() + monkeypatch.setattr(simulation_ipc_module, "logger", fake_logger) + monkeypatch.setattr(simulation_ipc_module.uuid, "uuid4", lambda: "fixed-command-id") + + original_sleep = simulation_ipc_module.time.sleep + + def _write_response(_seconds): + response_file = tmp_path / "ipc_responses" / "fixed-command-id.json" + response_file.parent.mkdir(parents=True, exist_ok=True) + response_file.write_text( + json.dumps( + { + "command_id": "fixed-command-id", + "status": "completed", + "result": {"ok": True}, + "timestamp": "2026-03-11T18:00:00", + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + monkeypatch.setattr(simulation_ipc_module.time, "sleep", original_sleep) + + with app.test_request_context(headers={"X-Locale": "en"}): + monkeypatch.setattr(simulation_ipc_module.time, "sleep", _write_response) + client = SimulationIPCClient(str(tmp_path)) + response = client.send_command(CommandType.CLOSE_ENV, {}, timeout=0.1, poll_interval=0.01) + + assert response.status.value == "completed" + assert fake_logger.info_messages == [ + "Sent IPC command: close_env, command_id=fixed-command-id", + "Received IPC response: command_id=fixed-command-id, status=completed", + ] + + +def test_prepare_simulation_empty_entities_uses_explicit_locale(tmp_path, monkeypatch): + monkeypatch.setattr(SimulationManager, "SIMULATION_DATA_DIR", str(tmp_path)) + + manager = SimulationManager() + state = manager.create_simulation( + project_id="project-1", + graph_id="graph-1", + ) + + monkeypatch.setattr( + "app.services.simulation_manager.ZepEntityReader", + lambda: SimpleNamespace( + filter_defined_entities=lambda **kwargs: SimpleNamespace( + filtered_count=0, + entity_types=set(), + entities=[], + ) + ), + ) + + result = manager.prepare_simulation( + simulation_id=state.simulation_id, + simulation_requirement="predict something", + document_text="context", + locale="en", + ) + + assert result.status == SimulationStatus.FAILED + assert result.error == "No matching entities were found. Check that the graph was built correctly." + + +def test_create_simulation_logs_use_english_request_locale(tmp_path, monkeypatch): + monkeypatch.setattr(SimulationManager, "SIMULATION_DATA_DIR", str(tmp_path)) + + info_messages = [] + monkeypatch.setattr( + "app.services.simulation_manager.logger", + SimpleNamespace(info=info_messages.append, error=lambda *_: None), + ) + + app = Flask(__name__) + manager = SimulationManager() + + with app.test_request_context(headers={"X-Locale": "en"}): + state = manager.create_simulation( + project_id="project-1", + graph_id="graph-1", + ) + + assert state.status == SimulationStatus.CREATED + assert info_messages == [ + f"Created simulation: {state.simulation_id}, project=project-1, graph=graph-1" + ] + + +def test_prepare_simulation_progress_messages_use_explicit_locale(tmp_path, monkeypatch): + monkeypatch.setattr(SimulationManager, "SIMULATION_DATA_DIR", str(tmp_path)) + + manager = SimulationManager() + state = manager.create_simulation( + project_id="project-1", + graph_id="graph-1", + ) + + monkeypatch.setattr( + "app.services.simulation_manager.ZepEntityReader", + lambda: SimpleNamespace( + filter_defined_entities=lambda **kwargs: SimpleNamespace( + filtered_count=2, + entity_types={"Person"}, + entities=[{"id": "a"}, {"id": "b"}], + ) + ), + ) + + class FakeProfileGenerator: + def __init__(self, *args, **kwargs): + pass + + def generate_profiles_from_entities(self, entities, progress_callback, **kwargs): + progress_callback(1, 2, "Profile 1") + progress_callback(2, 2, "Profile 2") + return [{"name": "A"}, {"name": "B"}] + + def save_profiles(self, **kwargs): + return None + + class FakeConfig: + generation_reasoning = "done" + + def to_json(self): + return "{}" + + class FakeConfigGenerator: + def __init__(self, *args, **kwargs): + pass + + def generate_config(self, **kwargs): + kwargs["progress_callback"](1, 4, "Generating time configuration...") + kwargs["progress_callback"](2, 4, "Generating event config and hot topics...") + kwargs["progress_callback"](3, 4, "Generating agent configs (1-2/2)...") + kwargs["progress_callback"](4, 4, "Generating platform configuration...") + return FakeConfig() + + monkeypatch.setattr("app.services.simulation_manager.OasisProfileGenerator", FakeProfileGenerator) + monkeypatch.setattr("app.services.simulation_manager.SimulationConfigGenerator", FakeConfigGenerator) + + events = [] + + manager.prepare_simulation( + simulation_id=state.simulation_id, + simulation_requirement="predict something", + document_text="context", + locale="en", + progress_callback=lambda stage, progress, message, **kwargs: events.append((stage, progress, message, kwargs)), + ) + + messages = [message for _, _, message, _ in events] + assert "Connecting to the Zep graph..." in messages + assert "Reading node data..." in messages + assert "Completed with 2 entities" in messages + assert "Starting generation..." in messages + assert "Saving profile files..." in messages + assert "Completed with 2 profiles" in messages + assert "Analyzing the simulation requirement..." in messages + assert "Calling the LLM to generate the config..." in messages + assert "Generating time configuration..." in messages + assert "Generating event config and hot topics..." in messages + assert "Generating agent configs (1-2/2)..." in messages + assert "Generating platform configuration..." in messages + assert "Saving the config file..." in messages + assert "Configuration generation completed" in messages + + generating_config_events = [(progress, message) for stage, progress, message, _ in events if stage == "generating_config"] + assert generating_config_events == [ + (0, "Analyzing the simulation requirement..."), + (30, "Calling the LLM to generate the config..."), + (38, "Generating time configuration..."), + (47, "Generating event config and hot topics..."), + (56, "Generating agent configs (1-2/2)..."), + (65, "Generating platform configuration..."), + (70, "Saving the config file..."), + (100, "Configuration generation completed"), + ] + + +def test_prepare_simulation_logs_success_message_in_english(tmp_path, monkeypatch): + monkeypatch.setattr(SimulationManager, "SIMULATION_DATA_DIR", str(tmp_path)) + + manager = SimulationManager() + state = manager.create_simulation( + project_id="project-1", + graph_id="graph-1", + ) + + monkeypatch.setattr( + "app.services.simulation_manager.ZepEntityReader", + lambda: SimpleNamespace( + filter_defined_entities=lambda **kwargs: SimpleNamespace( + filtered_count=2, + entity_types={"Person"}, + entities=[{"id": "a"}, {"id": "b"}], + ) + ), + ) + + class FakeProfileGenerator: + def __init__(self, *args, **kwargs): + pass + + def generate_profiles_from_entities(self, entities, progress_callback, **kwargs): + return [{"name": "A"}, {"name": "B"}] + + def save_profiles(self, **kwargs): + return None + + class FakeConfig: + generation_reasoning = "done" + + def to_json(self): + return "{}" + + class FakeConfigGenerator: + def __init__(self, *args, **kwargs): + pass + + def generate_config(self, **kwargs): + return FakeConfig() + + info_messages = [] + monkeypatch.setattr("app.services.simulation_manager.OasisProfileGenerator", FakeProfileGenerator) + monkeypatch.setattr("app.services.simulation_manager.SimulationConfigGenerator", FakeConfigGenerator) + monkeypatch.setattr( + "app.services.simulation_manager.logger", + SimpleNamespace(info=info_messages.append, error=lambda *_: None), + ) + + manager.prepare_simulation( + simulation_id=state.simulation_id, + simulation_requirement="predict something", + document_text="context", + locale="en", + ) + + assert info_messages[-1] == ( + f"Simulation preparation completed: {state.simulation_id}, entities=2, profiles=2" + ) + + +def test_prepare_simulation_logs_failure_message_in_english(tmp_path, monkeypatch): + monkeypatch.setattr(SimulationManager, "SIMULATION_DATA_DIR", str(tmp_path)) + + manager = SimulationManager() + state = manager.create_simulation( + project_id="project-1", + graph_id="graph-1", + ) + + monkeypatch.setattr( + "app.services.simulation_manager.ZepEntityReader", + lambda: SimpleNamespace( + filter_defined_entities=lambda **kwargs: SimpleNamespace( + filtered_count=1, + entity_types={"Person"}, + entities=[{"id": "a"}], + ) + ), + ) + + class FailingProfileGenerator: + def __init__(self, *args, **kwargs): + pass + + def generate_profiles_from_entities(self, **kwargs): + raise RuntimeError("profile generation blew up") + + error_messages = [] + monkeypatch.setattr("app.services.simulation_manager.OasisProfileGenerator", FailingProfileGenerator) + monkeypatch.setattr( + "app.services.simulation_manager.logger", + SimpleNamespace(info=lambda *_: None, error=error_messages.append), + ) + + try: + manager.prepare_simulation( + simulation_id=state.simulation_id, + simulation_requirement="predict something", + document_text="context", + locale="en", + ) + except RuntimeError as exc: + assert str(exc) == "profile generation blew up" + else: + raise AssertionError("expected RuntimeError from profile generation") + + assert error_messages[0] == ( + f"Simulation preparation failed: {state.simulation_id}, error=profile generation blew up" + ) + + +def test_get_profiles_falls_back_to_enabled_twitter_platform_and_reads_csv(tmp_path, monkeypatch): + monkeypatch.setattr(SimulationManager, "SIMULATION_DATA_DIR", str(tmp_path)) + + manager = SimulationManager() + state = manager.create_simulation( + project_id="project-1", + graph_id="graph-1", + enable_twitter=True, + enable_reddit=False, + ) + + sim_dir = tmp_path / state.simulation_id + with (sim_dir / "twitter_profiles.csv").open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=["username", "platform", "bio"]) + writer.writeheader() + writer.writerow({"username": "tw-user", "platform": "twitter", "bio": "Tracks breaking news"}) + + profiles = manager.get_profiles(state.simulation_id, platform="reddit") + + assert profiles == [ + { + "username": "tw-user", + "platform": "twitter", + "bio": "Tracks breaking news", + } + ] + + +def test_stop_simulation_not_running_uses_english_request_locale(monkeypatch): + app = Flask(__name__) + + monkeypatch.setattr( + SimulationRunner, + "get_run_state", + classmethod( + lambda cls, simulation_id: SimulationRunState( + simulation_id=simulation_id, + runner_status=RunnerStatus.STOPPED, + ) + ), + ) + + with app.test_request_context(headers={"X-Locale": "en"}): + try: + SimulationRunner.stop_simulation("sim-123") + except ValueError as exc: + assert str(exc) == "The simulation is not running: sim-123, status=stopped" + else: + raise AssertionError("expected ValueError for a stopped simulation") + + +def test_interview_all_agents_missing_config_uses_english_request_locale(tmp_path, monkeypatch): + app = Flask(__name__) + monkeypatch.setattr(SimulationRunner, "RUN_STATE_DIR", str(tmp_path)) + (tmp_path / "sim-123").mkdir() + + with app.test_request_context(headers={"X-Locale": "en"}): + try: + SimulationRunner.interview_all_agents("sim-123", "hello") + except ValueError as exc: + assert str(exc) == "The simulation config does not exist yet. Call /prepare first." + else: + raise AssertionError("expected ValueError for a missing simulation config") + + +def test_simulation_runner_interview_logs_use_english_request_locale(tmp_path, monkeypatch): + app = Flask(__name__) + monkeypatch.setattr(SimulationRunner, "RUN_STATE_DIR", str(tmp_path)) + (tmp_path / "sim-123").mkdir() + + class FakeIPCClient: + def __init__(self, sim_dir): + assert sim_dir == str(tmp_path / "sim-123") + + def check_env_alive(self): + return True + + def send_interview(self, **kwargs): + return SimpleNamespace( + status=SimpleNamespace(value="completed"), + result={"ok": True}, + timestamp="2026-03-11T18:35:00Z", + ) + + info_messages = [] + monkeypatch.setattr("app.services.simulation_runner.SimulationIPCClient", FakeIPCClient) + monkeypatch.setattr("app.services.simulation_runner.logger", SimpleNamespace(info=info_messages.append)) + + with app.test_request_context(headers={"X-Locale": "en"}): + result = SimulationRunner.interview_agent("sim-123", 7, "hello", platform="twitter") + + assert result["success"] is True + assert info_messages == [ + "Sent interview command: simulation_id=sim-123, agent_id=7, platform=twitter" + ] + + +def test_simulation_runner_batch_and_global_logs_use_english_request_locale(tmp_path, monkeypatch): + app = Flask(__name__) + monkeypatch.setattr(SimulationRunner, "RUN_STATE_DIR", str(tmp_path)) + sim_dir = tmp_path / "sim-123" + sim_dir.mkdir() + (sim_dir / "simulation_config.json").write_text( + json.dumps({"agent_configs": [{"agent_id": 3}, {"agent_id": 9}]}, ensure_ascii=False), + encoding="utf-8", + ) + + class FakeIPCClient: + def __init__(self, current_sim_dir): + assert current_sim_dir == str(sim_dir) + + def check_env_alive(self): + return True + + def send_batch_interview(self, **kwargs): + return SimpleNamespace( + status=SimpleNamespace(value="completed"), + result={"ok": True}, + timestamp="2026-03-11T18:35:00Z", + ) + + info_messages = [] + monkeypatch.setattr("app.services.simulation_runner.SimulationIPCClient", FakeIPCClient) + monkeypatch.setattr("app.services.simulation_runner.logger", SimpleNamespace(info=info_messages.append)) + + with app.test_request_context(headers={"X-Locale": "en"}): + batch_result = SimulationRunner.interview_agents_batch( + "sim-123", + interviews=[{"agent_id": 1, "prompt": "a"}, {"agent_id": 2, "prompt": "b"}], + platform="reddit", + ) + global_result = SimulationRunner.interview_all_agents("sim-123", "hello", platform="twitter") + + assert batch_result["success"] is True + assert global_result["success"] is True + assert info_messages == [ + "Sent batch interview command: simulation_id=sim-123, count=2, platform=reddit", + "Sent global interview command: simulation_id=sim-123, agent_count=2, platform=twitter", + "Sent batch interview command: simulation_id=sim-123, count=2, platform=twitter", + ] + + +def test_simulation_runner_close_env_log_uses_explicit_locale(tmp_path, monkeypatch): + monkeypatch.setattr(SimulationRunner, "RUN_STATE_DIR", str(tmp_path)) + (tmp_path / "sim-123").mkdir() + + class FakeIPCClient: + def __init__(self, sim_dir): + assert sim_dir == str(tmp_path / "sim-123") + + def check_env_alive(self): + return True + + def send_close_env(self, **kwargs): + return SimpleNamespace( + status=SimpleNamespace(value="completed"), + result={"ok": True}, + timestamp="2026-03-11T18:35:00Z", + ) + + info_messages = [] + monkeypatch.setattr("app.services.simulation_runner.SimulationIPCClient", FakeIPCClient) + monkeypatch.setattr("app.services.simulation_runner.logger", SimpleNamespace(info=info_messages.append)) + + result = SimulationRunner.close_simulation_env("sim-123", locale="en") + + assert result["success"] is True + assert info_messages == [ + "Sent close-environment command: simulation_id=sim-123" + ] + + +def test_get_run_state_logs_english_load_failure(tmp_path, monkeypatch): + app = Flask(__name__) + monkeypatch.setattr(SimulationRunner, "RUN_STATE_DIR", str(tmp_path)) + sim_dir = tmp_path / "sim-123" + sim_dir.mkdir() + (sim_dir / "run_state.json").write_text("{not-json", encoding="utf-8") + SimulationRunner._run_states.pop("sim-123", None) + + error_messages = [] + monkeypatch.setattr("app.services.simulation_runner.logger", SimpleNamespace(error=error_messages.append)) + + with app.test_request_context(headers={"X-Locale": "en"}): + result = SimulationRunner.get_run_state("sim-123") + + assert result is None + assert error_messages == [ + "Failed to load the run state: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)" + ] + + +def test_get_interview_history_logs_english_read_failure(tmp_path, monkeypatch): + app = Flask(__name__) + monkeypatch.setattr(SimulationRunner, "RUN_STATE_DIR", str(tmp_path)) + sim_dir = tmp_path / "sim-123" / "twitter" + sim_dir.mkdir(parents=True) + (tmp_path / "sim-123" / "twitter_simulation.db").write_bytes(b"") + + error_messages = [] + monkeypatch.setattr( + "app.services.simulation_runner.logger", + SimpleNamespace(error=error_messages.append), + ) + + with app.test_request_context(headers={"X-Locale": "en"}): + result = SimulationRunner.get_interview_history("sim-123", platform="twitter", limit=5) + + assert result == [] + assert error_messages == [ + "Failed to read interview history (twitter): no such table: trace" + ] diff --git a/backend/tests/test_task_manager.py b/backend/tests/test_task_manager.py new file mode 100644 index 00000000..81195b5e --- /dev/null +++ b/backend/tests/test_task_manager.py @@ -0,0 +1,76 @@ +from datetime import datetime, timedelta +from pathlib import Path + +import pytest + +from app.models.task import TaskManager, TaskStatus + + +def reset_task_manager_singleton(): + TaskManager._instance = None + + +@pytest.fixture(autouse=True) +def isolated_task_manager(): + reset_task_manager_singleton() + yield + reset_task_manager_singleton() + + +def test_task_manager_persists_and_reload_tasks(tmp_path, monkeypatch): + monkeypatch.setattr("app.models.task.Config.UPLOAD_FOLDER", str(tmp_path)) + + manager = TaskManager() + task_id = manager.create_task("graph_build", metadata={"project_id": "proj_123"}) + manager.update_task( + task_id, + status=TaskStatus.PROCESSING, + progress=55, + message="still running", + progress_detail={"stage": "ontology"}, + ) + + state_path = Path(tmp_path) / "tasks" / "task_state.json" + assert state_path.exists() + + reset_task_manager_singleton() + reloaded = TaskManager() + task = reloaded.get_task(task_id) + + assert task is not None + assert task.status == TaskStatus.PROCESSING + assert task.progress == 55 + assert task.message == "still running" + assert task.metadata == {"project_id": "proj_123"} + assert task.progress_detail == {"stage": "ontology"} + + +def test_task_manager_ignores_invalid_persisted_state(tmp_path, monkeypatch): + monkeypatch.setattr("app.models.task.Config.UPLOAD_FOLDER", str(tmp_path)) + state_path = Path(tmp_path) / "tasks" / "task_state.json" + state_path.parent.mkdir(parents=True, exist_ok=True) + state_path.write_text("{invalid json", encoding="utf-8") + + manager = TaskManager() + + assert manager.list_tasks() == [] + + +def test_task_manager_cleanup_updates_persisted_state(tmp_path, monkeypatch): + monkeypatch.setattr("app.models.task.Config.UPLOAD_FOLDER", str(tmp_path)) + + manager = TaskManager() + task_id = manager.create_task("report") + with manager._task_lock: + task = manager._tasks[task_id] + task.status = TaskStatus.COMPLETED + task.created_at = datetime.now() - timedelta(hours=48) + task.updated_at = task.created_at + manager._persist_tasks() + + manager.cleanup_old_tasks(max_age_hours=24) + assert manager.get_task(task_id) is None + + reset_task_manager_singleton() + reloaded = TaskManager() + assert reloaded.get_task(task_id) is None diff --git a/backend/tests/test_zep_entity_reader.py b/backend/tests/test_zep_entity_reader.py new file mode 100644 index 00000000..50efc798 --- /dev/null +++ b/backend/tests/test_zep_entity_reader.py @@ -0,0 +1,293 @@ +import sys +from types import ModuleType, SimpleNamespace + + +fake_zep_cloud = ModuleType("zep_cloud") +fake_zep_client = ModuleType("zep_cloud.client") +fake_zep_client.Zep = object +fake_zep_cloud.client = fake_zep_client +fake_zep_cloud.__getattr__ = lambda name: object +sys.modules.setdefault("zep_cloud", fake_zep_cloud) +sys.modules.setdefault("zep_cloud.client", fake_zep_client) + +from app.services.zep_entity_reader import ZepEntityReader + + +def _build_reader(): + reader = ZepEntityReader.__new__(ZepEntityReader) + reader.client = SimpleNamespace() + return reader + + +class _FakeLogger: + def __init__(self): + self.messages = [] + + def info(self, message, *args): + self.messages.append(("info", message % args if args else message)) + + def warning(self, message, *args): + self.messages.append(("warning", message % args if args else message)) + + def error(self, message, *args): + self.messages.append(("error", message % args if args else message)) + + +def test_filter_defined_entities_collapses_title_prefixed_duplicate_people(monkeypatch): + reader = _build_reader() + nodes = [ + { + "uuid": "node-short", + "name": "特朗普", + "labels": ["Entity", "Person"], + "summary": "美国前总统。", + "attributes": {"source": "short"}, + }, + { + "uuid": "node-long", + "name": "美国总统特朗普", + "labels": ["Entity", "Person"], + "summary": "在新闻事件中的核心人物,带有更长摘要。", + "attributes": {"title": "president"}, + }, + ] + edges = [ + { + "uuid": "edge-1", + "name": "mentions", + "fact": "与选举相关", + "source_node_uuid": "node-short", + "target_node_uuid": "node-long", + "attributes": {}, + } + ] + + monkeypatch.setattr(reader, "get_all_nodes", lambda graph_id: nodes) + monkeypatch.setattr(reader, "get_all_edges", lambda graph_id: edges) + + result = reader.filter_defined_entities("graph-1", enrich_with_edges=True) + + assert result.filtered_count == 1 + entity = result.entities[0] + assert entity.get_entity_type() == "Person" + assert entity.name == "美国总统特朗普" + assert entity.summary == "在新闻事件中的核心人物,带有更长摘要。" + assert entity.attributes == {"source": "short", "title": "president"} + assert len(entity.related_edges) == 1 + assert any(node["name"] == "美国总统特朗普" for node in entity.related_nodes) + + +def test_filter_defined_entities_keeps_distinct_people_separate(monkeypatch): + reader = _build_reader() + nodes = [ + { + "uuid": "node-1", + "name": "特朗普", + "labels": ["Entity", "Person"], + "summary": "人物 A", + "attributes": {}, + }, + { + "uuid": "node-2", + "name": "拜登", + "labels": ["Entity", "Person"], + "summary": "人物 B", + "attributes": {}, + }, + ] + + monkeypatch.setattr(reader, "get_all_nodes", lambda graph_id: nodes) + monkeypatch.setattr(reader, "get_all_edges", lambda graph_id: []) + + result = reader.filter_defined_entities("graph-1", enrich_with_edges=False) + + assert result.filtered_count == 2 + assert [entity.name for entity in result.entities] == ["特朗普", "拜登"] + + +def test_filter_defined_entities_localizes_english_diagnostics(monkeypatch): + reader = _build_reader() + reader.locale = "en" + fake_logger = _FakeLogger() + nodes = [ + { + "uuid": "node-short", + "name": "特朗普", + "labels": ["Entity", "Person"], + "summary": "Short summary", + "attributes": {}, + }, + { + "uuid": "node-long", + "name": "美国总统特朗普", + "labels": ["Entity", "Person"], + "summary": "Longer summary", + "attributes": {}, + }, + ] + monkeypatch.setattr("app.services.zep_entity_reader.logger", fake_logger) + monkeypatch.setattr(reader, "get_all_nodes", lambda graph_id: nodes) + monkeypatch.setattr(reader, "get_all_edges", lambda graph_id: []) + + result = reader.filter_defined_entities("graph-1", enrich_with_edges=False) + + assert result.filtered_count == 1 + messages = [message for _, message in fake_logger.messages] + assert messages[0] == "Starting entity filtering for graph graph-1..." + assert "Duplicate entity alias collapse completed: merged 1 duplicate candidate(s)" in messages + assert "Entity filtering completed: total nodes 2, matched 1, entity types: {'Person'}" in messages + assert all("筛选完成" not in message for message in messages) + + +def test_call_with_retry_localizes_english_retry_logs(monkeypatch): + reader = _build_reader() + reader.locale = "en" + fake_logger = _FakeLogger() + monkeypatch.setattr("app.services.zep_entity_reader.logger", fake_logger) + sleep_calls = [] + monkeypatch.setattr("app.services.zep_entity_reader.time.sleep", sleep_calls.append) + + attempts = {"count": 0} + + def flaky(): + attempts["count"] += 1 + if attempts["count"] < 3: + raise RuntimeError("gateway unavailable") + return "ok" + + result = reader._call_with_retry(flaky, "fetch node edges (node=abc12345...)", max_retries=3, initial_delay=0.5) + + assert result == "ok" + assert sleep_calls == [0.5, 1.0] + messages = fake_logger.messages + assert messages == [ + ( + "warning", + "Zep fetch node edges (node=abc12345...) failed on attempt 1: gateway unavailable, retrying in 0.5s...", + ), + ( + "warning", + "Zep fetch node edges (node=abc12345...) failed on attempt 2: gateway unavailable, retrying in 1.0s...", + ), + ] + + +def test_get_node_edges_localizes_english_failure_message(monkeypatch): + reader = _build_reader() + reader.locale = "en" + fake_logger = _FakeLogger() + reader.client = SimpleNamespace( + graph=SimpleNamespace( + node=SimpleNamespace( + get_entity_edges=lambda node_uuid: (_ for _ in ()).throw(RuntimeError("forbidden")) + ) + ) + ) + monkeypatch.setattr("app.services.zep_entity_reader.logger", fake_logger) + monkeypatch.setattr("app.services.zep_entity_reader.time.sleep", lambda *_: None) + + result = reader.get_node_edges("node-12345678") + + assert result == [] + assert fake_logger.messages[-1] == ( + "warning", + "Failed to fetch edges for node node-12345678: forbidden", + ) + + +def test_get_entity_with_context_includes_alias_linked_relations(monkeypatch): + reader = _build_reader() + nodes = [ + { + "uuid": "node-short", + "name": "特朗普", + "labels": ["Entity", "Person"], + "summary": "", + "attributes": {"source": "short"}, + }, + { + "uuid": "node-long", + "name": "美国总统特朗普", + "labels": ["Entity", "Person"], + "summary": "更完整的人物摘要。", + "attributes": {"title": "president"}, + }, + { + "uuid": "node-biden", + "name": "拜登", + "labels": ["Entity", "Person"], + "summary": "另一个人物。", + "attributes": {}, + }, + ] + edges = [ + { + "uuid": "edge-1", + "name": "met_with", + "fact": "美国总统特朗普会见了拜登", + "source_node_uuid": "node-long", + "target_node_uuid": "node-biden", + "attributes": {}, + } + ] + + reader.client = SimpleNamespace( + graph=SimpleNamespace( + node=SimpleNamespace( + get=lambda uuid_: SimpleNamespace( + uuid_=uuid_, + name="特朗普", + labels=["Entity", "Person"], + summary="", + attributes={"source": "short"}, + ) + ) + ) + ) + monkeypatch.setattr(reader, "get_all_nodes", lambda graph_id: nodes) + monkeypatch.setattr(reader, "get_all_edges", lambda graph_id: edges) + + entity = reader.get_entity_with_context("graph-1", "node-short") + + assert entity is not None + assert entity.name == "美国总统特朗普" + assert entity.summary == "更完整的人物摘要。" + assert entity.attributes == {"source": "short", "title": "president"} + assert entity.related_edges == [ + { + "direction": "outgoing", + "edge_name": "met_with", + "fact": "美国总统特朗普会见了拜登", + "target_node_uuid": "node-biden", + } + ] + assert entity.related_nodes == [ + { + "uuid": "node-biden", + "name": "拜登", + "labels": ["Entity", "Person"], + "summary": "另一个人物。", + } + ] + + +def test_get_entity_with_context_localizes_english_failure_log(monkeypatch): + reader = _build_reader() + reader.locale = "en" + fake_logger = _FakeLogger() + monkeypatch.setattr("app.services.zep_entity_reader.logger", fake_logger) + monkeypatch.setattr(reader, "get_all_nodes", lambda graph_id: []) + monkeypatch.setattr(reader, "get_all_edges", lambda graph_id: []) + monkeypatch.setattr( + reader, + "_call_with_retry", + lambda func, operation_name: (_ for _ in ()).throw(RuntimeError("node exploded")), + ) + + entity = reader.get_entity_with_context("graph-1", "node-404") + + assert entity is None + assert fake_logger.messages[-1] == ( + "error", + "Failed to fetch entity node-404: node exploded", + ) diff --git a/backend/tests/test_zep_paging_i18n.py b/backend/tests/test_zep_paging_i18n.py new file mode 100644 index 00000000..a181b646 --- /dev/null +++ b/backend/tests/test_zep_paging_i18n.py @@ -0,0 +1,176 @@ +import sys +from types import ModuleType, SimpleNamespace +from unittest.mock import Mock + +from flask import Flask + + +fake_zep_cloud = ModuleType("zep_cloud") +fake_zep_client = ModuleType("zep_cloud.client") +fake_zep_client.Zep = object +fake_zep_cloud.client = fake_zep_client +fake_zep_cloud.InternalServerError = RuntimeError +fake_zep_cloud.EpisodeData = object +fake_zep_cloud.EntityEdgeSourceTarget = object +fake_zep_cloud.__getattr__ = lambda name: object +sys.modules.setdefault("zep_cloud", fake_zep_cloud) +sys.modules.setdefault("zep_cloud.client", fake_zep_client) + +from app.utils import zep_paging + + +def test_fetch_page_with_retry_logs_english_messages(monkeypatch): + app = Flask(__name__) + warning = Mock() + error = Mock() + sleep_calls: list[float] = [] + attempts = {"count": 0} + + def flaky_call(): + attempts["count"] += 1 + if attempts["count"] < 3: + raise RuntimeError("gateway unavailable") + return ["ok"] + + monkeypatch.setattr(zep_paging.logger, "warning", warning) + monkeypatch.setattr(zep_paging.logger, "error", error) + monkeypatch.setattr(zep_paging.time, "sleep", sleep_calls.append) + + with app.test_request_context(headers={"X-Locale": "en"}): + result = zep_paging._fetch_page_with_retry( + flaky_call, + max_retries=3, + retry_delay=0.5, + page_description="fetch nodes page 1 (graph=graph-1)", + ) + + assert result == ["ok"] + assert sleep_calls == [0.5, 1.0] + assert warning.call_args_list == [ + ( + ( + "Zep fetch nodes page 1 (graph=graph-1) failed on attempt 1: gateway unavailable, retrying in 0.5s...", + ), + {}, + ), + ( + ( + "Zep fetch nodes page 1 (graph=graph-1) failed on attempt 2: gateway unavailable, retrying in 1.0s...", + ), + {}, + ), + ] + error.assert_not_called() + + +def test_fetch_page_with_retry_logs_english_final_failure(monkeypatch): + error = Mock() + + monkeypatch.setattr(zep_paging.logger, "error", error) + + def always_fail(): + raise RuntimeError("still down") + + try: + zep_paging._fetch_page_with_retry( + always_fail, + max_retries=1, + retry_delay=0.5, + page_description="fetch edges page 2 (graph=graph-2)", + locale="en", + ) + except RuntimeError as exc: + assert str(exc) == "still down" + else: + raise AssertionError("expected RuntimeError") + + error.assert_called_once_with( + "Zep fetch edges page 2 (graph=graph-2) still failed after 1 attempts: still down" + ) + + +def test_fetch_all_nodes_localizes_limit_and_missing_uuid_logs(monkeypatch): + warning = Mock() + client = SimpleNamespace( + graph=SimpleNamespace( + node=SimpleNamespace( + get_by_graph_id=lambda *_args, **kwargs: [ + SimpleNamespace(uuid_="node-1"), + SimpleNamespace(uuid_="node-2"), + ] + if "uuid_cursor" not in kwargs + else [ + SimpleNamespace(uuid_="node-3"), + SimpleNamespace(name="missing-uuid"), + ] + ) + ) + ) + + monkeypatch.setattr(zep_paging.logger, "warning", warning) + + nodes = zep_paging.fetch_all_nodes( + client, + "graph-3", + page_size=2, + max_items=10, + locale="en", + ) + + assert len(nodes) == 4 + assert warning.call_args_list == [ + ( + ( + "A node is missing the uuid field; stopping pagination after reading 4 nodes", + ), + {}, + ) + ] + + warning.reset_mock() + + limited_nodes = zep_paging.fetch_all_nodes( + client, + "graph-3", + page_size=2, + max_items=2, + locale="en", + ) + + assert len(limited_nodes) == 2 + warning.assert_called_once_with( + "Node count reached the limit (2); stopping pagination for graph graph-3" + ) + + +def test_fetch_all_edges_localizes_missing_uuid_logs(monkeypatch): + warning = Mock() + client = SimpleNamespace( + graph=SimpleNamespace( + edge=SimpleNamespace( + get_by_graph_id=lambda *_args, **kwargs: [ + SimpleNamespace(uuid_="edge-1"), + SimpleNamespace(uuid_="edge-2"), + ] + if "uuid_cursor" not in kwargs + else [ + SimpleNamespace(uuid_="edge-3"), + SimpleNamespace(name="missing-uuid"), + ] + ) + ) + ) + + monkeypatch.setattr(zep_paging.logger, "warning", warning) + + edges = zep_paging.fetch_all_edges( + client, + "graph-4", + page_size=2, + locale="en", + ) + + assert len(edges) == 4 + warning.assert_called_once_with( + "An edge is missing the uuid field; stopping pagination after reading 4 edges" + ) diff --git a/backend/tests/test_zep_tools_dedup.py b/backend/tests/test_zep_tools_dedup.py new file mode 100644 index 00000000..4f034f88 --- /dev/null +++ b/backend/tests/test_zep_tools_dedup.py @@ -0,0 +1,841 @@ +from __future__ import annotations + +import sys +from types import ModuleType + +from flask import Flask + +fake_zep_cloud = ModuleType("zep_cloud") +fake_zep_client = ModuleType("zep_cloud.client") +fake_zep_client.Zep = object +fake_zep_cloud.client = fake_zep_client +fake_zep_cloud.__getattr__ = lambda name: object +sys.modules.setdefault("zep_cloud", fake_zep_cloud) +sys.modules.setdefault("zep_cloud.client", fake_zep_client) + +from app.services.zep_tools import NodeInfo, SearchResult, ZepToolsService + + +def _make_service() -> ZepToolsService: + return ZepToolsService.__new__(ZepToolsService) + + +class _FakeSearchNode: + def __init__(self, uuid, name, labels, summary): + self.uuid = uuid + self.name = name + self.labels = labels + self.summary = summary + + +class _FakeSearchEdge: + def __init__(self, uuid, name, fact, source_node_uuid, target_node_uuid): + self.uuid = uuid + self.name = name + self.fact = fact + self.source_node_uuid = source_node_uuid + self.target_node_uuid = target_node_uuid + + +class _FakeSearchResults: + def __init__(self, nodes, edges): + self.nodes = nodes + self.edges = edges + + +def test_get_entities_by_type_collapses_obvious_alias_duplicates(): + service = _make_service() + service.get_all_nodes = lambda graph_id: [ + NodeInfo( + uuid="node-short", + name="特朗普", + labels=["Entity", "PublicFigure"], + summary="", + attributes={}, + locale="en", + ), + NodeInfo( + uuid="node-long", + name="美国总统特朗普", + labels=["Entity", "PublicFigure"], + summary="Former president and recurring political actor.", + attributes={"country": "US"}, + locale="en", + ), + NodeInfo( + uuid="node-biden", + name="拜登", + labels=["Entity", "PublicFigure"], + summary="", + attributes={}, + locale="en", + ), + ] + + result = service.get_entities_by_type("graph-1", "PublicFigure") + + assert [node.name for node in result] == ["特朗普", "拜登"] + assert result[0].summary == "Former president and recurring political actor." + assert result[0].attributes == {"country": "US"} + assert result[0].alias_names == ["特朗普", "美国总统特朗普"] + + +def test_panorama_search_reports_deduplicated_entity_count(): + app = Flask(__name__) + service = _make_service() + service.get_all_nodes = lambda graph_id: [ + NodeInfo( + uuid="node-short", + name="特朗普", + labels=["Entity", "PublicFigure"], + summary="", + attributes={}, + locale="en", + ), + NodeInfo( + uuid="node-long", + name="美国总统特朗普", + labels=["Entity", "PublicFigure"], + summary="Former president and recurring political actor.", + attributes={"country": "US"}, + locale="en", + ), + ] + service.get_all_edges = lambda graph_id, include_temporal=True: [ + type( + "Edge", + (), + { + "uuid": "edge-1", + "name": "MENTIONS", + "fact": "特朗普 criticized the proposal.", + "source_node_uuid": "node-short", + "target_node_uuid": "node-wh", + "source_node_name": None, + "target_node_name": None, + "created_at": None, + "valid_at": None, + "invalid_at": None, + "expired_at": None, + "locale": "en", + }, + )(), + type( + "Edge", + (), + { + "uuid": "edge-2", + "name": "MENTIONS", + "fact": "特朗普 criticized the proposal.", + "source_node_uuid": "node-long", + "target_node_uuid": "node-wh", + "source_node_name": None, + "target_node_name": None, + "created_at": None, + "valid_at": None, + "invalid_at": None, + "expired_at": None, + "locale": "en", + }, + )(), + ] + service._locale = lambda: "en" + + with app.test_request_context(headers={"X-Locale": "en"}): + result = service.panorama_search("graph-1", "trump") + + assert result.total_nodes == 1 + assert [node.name for node in result.all_nodes] == ["特朗普"] + assert result.total_edges == 1 + assert len(result.active_facts) == 1 + assert result.active_facts == ["特朗普 criticized the proposal."] + assert result.all_edges[0].source_node_uuid == "node-short" + assert result.all_edges[0].source_node_name == "特朗普" + assert result.to_text().count("**特朗普**") == 1 + + +def test_insight_forge_collapses_duplicate_aliases_in_entities_and_relationships(): + app = Flask(__name__) + service = _make_service() + service._locale = lambda: "en" + service._generate_sub_queries = lambda **kwargs: ["trump reaction"] + service.search_graph = lambda **kwargs: SearchResult( + facts=[ + "特朗普 criticized the proposal.", + "美国总统特朗普 met with advisers at the White House.", + ], + edges=[ + { + "source_node_uuid": "node-short", + "target_node_uuid": "node-wh", + "name": "MENTIONS", + }, + { + "source_node_uuid": "node-long", + "target_node_uuid": "node-wh", + "name": "MENTIONS", + }, + ], + nodes=[], + query=str(kwargs.get("query", "")), + total_count=2, + locale="en", + ) + node_lookup = { + "node-short": NodeInfo( + uuid="node-short", + name="特朗普", + labels=["Entity", "PublicFigure"], + summary="", + attributes={}, + locale="en", + ), + "node-long": NodeInfo( + uuid="node-long", + name="美国总统特朗普", + labels=["Entity", "PublicFigure"], + summary="Former president and recurring political actor.", + attributes={"country": "US"}, + locale="en", + ), + "node-wh": NodeInfo( + uuid="node-wh", + name="白宫", + labels=["Entity", "Organization"], + summary="Executive residence and workplace.", + attributes={}, + locale="en", + ), + } + service.get_node_detail = lambda uuid, graph_id=None: node_lookup[uuid] + + with app.test_request_context(headers={"X-Locale": "en"}): + result = service.insight_forge("graph-1", "How did Trump react?", "Political crisis") + + assert result.total_entities == 2 + assert sorted(entity["name"] for entity in result.entity_insights) == ["特朗普", "白宫"] + trump_entity = next(entity for entity in result.entity_insights if entity["name"] == "特朗普") + assert "特朗普 criticized the proposal." in trump_entity["related_facts"] + assert "美国总统特朗普 met with advisers at the White House." in trump_entity["related_facts"] + assert result.total_relationships == 1 + assert result.relationship_chains == ["特朗普 --[MENTIONS]--> 白宫"] + + +def test_search_graph_collapses_duplicate_aliases_and_remaps_edges(): + service = _make_service() + service._locale = lambda: "en" + service._text = lambda zh, en, locale: en if locale == "en" else zh + service._log = lambda *args, **kwargs: None + service._call_with_retry = lambda func, operation_name: func() + service.client = type( + "FakeClient", + (), + { + "graph": type( + "FakeGraph", + (), + { + "search": staticmethod( + lambda **kwargs: _FakeSearchResults( + nodes=[ + _FakeSearchNode( + "node-short", + "特朗普", + ["Entity", "PublicFigure"], + "", + ), + _FakeSearchNode( + "node-long", + "美国总统特朗普", + ["Entity", "PublicFigure"], + "Former president and recurring political actor.", + ), + ], + edges=[ + _FakeSearchEdge( + "edge-1", + "MENTIONS", + "特朗普 criticized the proposal.", + "node-short", + "node-wh", + ), + _FakeSearchEdge( + "edge-2", + "MENTIONS", + "特朗普 criticized the proposal.", + "node-long", + "node-wh", + ), + ], + ) + ) + }, + )() + }, + )() + + result = service.search_graph("graph-1", "trump", limit=10, scope="both") + + assert [node["name"] for node in result.nodes] == ["特朗普"] + assert result.facts == [ + "特朗普 criticized the proposal.", + "[特朗普]: Former president and recurring political actor.", + ] + assert result.nodes[0]["alias_names"] == ["特朗普", "美国总统特朗普"] + assert len(result.edges) == 1 + assert result.edges[0]["source_node_uuid"] == "node-short" + + +def test_local_search_collapses_duplicate_aliases_and_remaps_edges(): + service = _make_service() + service._locale = lambda: "en" + service._log = lambda *args, **kwargs: None + service.get_all_nodes = lambda graph_id: [ + NodeInfo( + uuid="node-short", + name="特朗普", + labels=["Entity", "PublicFigure"], + summary="", + attributes={}, + locale="en", + ), + NodeInfo( + uuid="node-long", + name="美国总统特朗普", + labels=["Entity", "PublicFigure"], + summary="Former president and recurring political actor.", + attributes={}, + locale="en", + ), + ] + service.get_all_edges = lambda graph_id: [ + type( + "Edge", + (), + { + "uuid": "edge-1", + "name": "MENTIONS", + "fact": "特朗普 criticized the proposal.", + "source_node_uuid": "node-short", + "target_node_uuid": "node-wh", + }, + )(), + type( + "Edge", + (), + { + "uuid": "edge-2", + "name": "MENTIONS", + "fact": "特朗普 criticized the proposal.", + "source_node_uuid": "node-long", + "target_node_uuid": "node-wh", + }, + )(), + ] + + result = service._local_search("graph-1", "特朗普", limit=10, scope="both") + + assert [node["name"] for node in result.nodes] == ["特朗普"] + assert result.facts == [ + "特朗普 criticized the proposal.", + "[特朗普]: Former president and recurring political actor.", + ] + assert len(result.edges) == 1 + assert result.edges[0]["source_node_uuid"] == "node-short" + + +def test_get_graph_statistics_collapses_duplicate_alias_counts(): + service = _make_service() + service.get_all_nodes = lambda graph_id: [ + NodeInfo( + uuid="node-short", + name="特朗普", + labels=["Entity", "PublicFigure"], + summary="", + attributes={}, + locale="en", + ), + NodeInfo( + uuid="node-long", + name="美国总统特朗普", + labels=["Entity", "PublicFigure"], + summary="Former president and recurring political actor.", + attributes={"country": "US"}, + locale="en", + ), + NodeInfo( + uuid="node-wh", + name="白宫", + labels=["Entity", "Organization"], + summary="Executive residence and workplace.", + attributes={}, + locale="en", + ), + ] + service.get_all_edges = lambda graph_id: [ + type( + "Edge", + (), + { + "uuid": "edge-1", + "name": "MENTIONS", + "fact": "特朗普 criticized the proposal.", + "source_node_uuid": "node-short", + "target_node_uuid": "node-wh", + "source_node_name": None, + "target_node_name": None, + "created_at": None, + "valid_at": None, + "invalid_at": None, + "expired_at": None, + "locale": "en", + }, + )(), + type( + "Edge", + (), + { + "uuid": "edge-2", + "name": "MENTIONS", + "fact": "特朗普 criticized the proposal.", + "source_node_uuid": "node-long", + "target_node_uuid": "node-wh", + "source_node_name": None, + "target_node_name": None, + "created_at": None, + "valid_at": None, + "invalid_at": None, + "expired_at": None, + "locale": "en", + }, + )(), + ] + + result = service.get_graph_statistics("graph-1") + + assert result["total_nodes"] == 2 + assert result["total_edges"] == 1 + assert result["entity_types"] == {"PublicFigure": 1, "Organization": 1} + assert result["relation_types"] == {"MENTIONS": 1} + + +def test_get_entity_summary_resolves_alias_query_to_canonical_node(): + service = _make_service() + service._locale = lambda: "en" + service.search_graph = lambda **kwargs: SearchResult( + facts=[ + "特朗普 criticized the proposal.", + "[特朗普]: Former president and recurring political actor.", + ], + edges=[], + nodes=[], + query=str(kwargs.get("query", "")), + total_count=2, + locale="en", + ) + service.get_all_nodes = lambda graph_id: [ + NodeInfo( + uuid="node-short", + name="特朗普", + labels=["Entity", "PublicFigure"], + summary="", + attributes={}, + locale="en", + ), + NodeInfo( + uuid="node-long", + name="美国总统特朗普", + labels=["Entity", "PublicFigure"], + summary="Former president and recurring political actor.", + attributes={"country": "US"}, + locale="en", + ), + ] + service.get_all_edges = lambda graph_id: [ + type( + "Edge", + (), + { + "uuid": "edge-1", + "name": "MENTIONS", + "fact": "特朗普 criticized the proposal.", + "source_node_uuid": "node-long", + "target_node_uuid": "node-wh", + "source_node_name": None, + "target_node_name": "白宫", + "created_at": None, + "valid_at": None, + "invalid_at": None, + "expired_at": None, + "locale": "en", + }, + )(), + ] + + result = service.get_entity_summary("graph-1", "美国总统特朗普") + + assert result["entity_info"]["name"] == "特朗普" + assert result["entity_info"]["summary"] == "Former president and recurring political actor." + assert result["entity_info"]["attributes"] == {"country": "US"} + assert result["entity_info"]["alias_names"] == ["特朗普", "美国总统特朗普"] + assert result["total_relations"] == 1 + assert result["related_edges"][0]["source_node_uuid"] == "node-short" + assert result["related_edges"][0]["source_node_name"] == "特朗普" + + +def test_get_entity_summary_includes_edges_attached_only_to_alias_uuid(): + service = _make_service() + service._locale = lambda: "en" + service.search_graph = lambda **kwargs: SearchResult( + facts=["特朗普 criticized the proposal."], + edges=[], + nodes=[], + query=str(kwargs.get("query", "")), + total_count=1, + locale="en", + ) + service.get_all_nodes = lambda graph_id: [ + NodeInfo( + uuid="node-short", + name="特朗普", + labels=["Entity", "PublicFigure"], + summary="Former president and recurring political actor.", + attributes={"country": "US"}, + locale="en", + ), + NodeInfo( + uuid="node-long", + name="美国总统特朗普", + labels=["Entity", "PublicFigure"], + summary="", + attributes={}, + locale="en", + ), + NodeInfo( + uuid="node-wh", + name="白宫", + labels=["Entity", "Organization"], + summary="Executive residence and workplace.", + attributes={}, + locale="en", + ), + ] + service.get_all_edges = lambda graph_id: [ + type( + "Edge", + (), + { + "uuid": "edge-1", + "name": "MENTIONS", + "fact": "特朗普 criticized the proposal.", + "source_node_uuid": "node-long", + "target_node_uuid": "node-wh", + "source_node_name": "美国总统特朗普", + "target_node_name": "白宫", + "created_at": None, + "valid_at": None, + "invalid_at": None, + "expired_at": None, + "locale": "en", + }, + )(), + ] + + result = service.get_entity_summary("graph-1", "特朗普") + + assert result["entity_info"]["name"] == "特朗普" + assert result["total_relations"] == 1 + assert result["related_edges"][0]["source_node_uuid"] == "node-short" + assert result["related_edges"][0]["source_node_name"] == "特朗普" + + +def test_get_node_edges_includes_alias_linked_edges_and_remaps_duplicates(): + service = _make_service() + service._locale = lambda: "en" + service.get_all_nodes = lambda graph_id: [ + NodeInfo( + uuid="node-short", + name="特朗普", + labels=["Entity", "PublicFigure"], + summary="", + attributes={}, + locale="en", + ), + NodeInfo( + uuid="node-long", + name="美国总统特朗普", + labels=["Entity", "PublicFigure"], + summary="Former president and recurring political actor.", + attributes={}, + locale="en", + ), + NodeInfo( + uuid="node-wh", + name="白宫", + labels=["Entity", "Organization"], + summary="Executive residence and workplace.", + attributes={}, + locale="en", + ), + ] + service.get_all_edges = lambda graph_id: [ + type( + "Edge", + (), + { + "uuid": "edge-1", + "name": "MENTIONS", + "fact": "美国总统特朗普 met with advisers at the White House.", + "source_node_uuid": "node-long", + "target_node_uuid": "node-wh", + "source_node_name": "美国总统特朗普", + "target_node_name": "白宫", + "created_at": None, + "valid_at": None, + "invalid_at": None, + "expired_at": None, + "locale": "en", + }, + )(), + type( + "Edge", + (), + { + "uuid": "edge-2", + "name": "MENTIONS", + "fact": "美国总统特朗普 met with advisers at the White House.", + "source_node_uuid": "node-short", + "target_node_uuid": "node-wh", + "source_node_name": "特朗普", + "target_node_name": "白宫", + "created_at": None, + "valid_at": None, + "invalid_at": None, + "expired_at": None, + "locale": "en", + }, + )(), + ] + + result = service.get_node_edges("graph-1", "node-short") + + assert len(result) == 1 + assert result[0].source_node_uuid == "node-short" + assert result[0].source_node_name == "特朗普" + assert result[0].target_node_uuid == "node-wh" + assert result[0].target_node_name == "白宫" + + +def test_get_all_nodes_collapses_obvious_alias_duplicates(monkeypatch): + service = _make_service() + service._locale = lambda: "en" + service.client = object() + + monkeypatch.setattr( + "app.services.zep_tools.fetch_all_nodes", + lambda client, graph_id, locale=None: [ + type( + "Node", + (), + { + "uuid": "node-short", + "name": "特朗普", + "labels": ["Entity", "PublicFigure"], + "summary": "", + "attributes": {}, + }, + )(), + type( + "Node", + (), + { + "uuid": "node-long", + "name": "美国总统特朗普", + "labels": ["Entity", "PublicFigure"], + "summary": "Former president and recurring political actor.", + "attributes": {"country": "US"}, + }, + )(), + type( + "Node", + (), + { + "uuid": "node-wh", + "name": "白宫", + "labels": ["Entity", "Organization"], + "summary": "Executive residence and workplace.", + "attributes": {}, + }, + )(), + ], + ) + + result = service.get_all_nodes("graph-1") + + assert [node.name for node in result] == ["特朗普", "白宫"] + assert result[0].uuid == "node-short" + assert result[0].summary == "Former president and recurring political actor." + assert result[0].attributes == {"country": "US"} + assert result[0].alias_names == ["特朗普", "美国总统特朗普"] + + +def test_get_node_detail_canonicalizes_alias_uuid_when_graph_id_is_provided(monkeypatch): + service = _make_service() + service._locale = lambda: "en" + service._call_with_retry = lambda func, operation_name: func() + service.client = type( + "FakeClient", + (), + { + "graph": type( + "FakeGraph", + (), + { + "node": type( + "FakeNodeApi", + (), + { + "get": staticmethod( + lambda uuid_: type( + "Node", + (), + { + "uuid": "node-long", + "name": "美国总统特朗普", + "labels": ["Entity", "PublicFigure"], + "summary": "", + "attributes": {}, + }, + )() + ) + }, + )() + }, + )() + }, + )() + + monkeypatch.setattr( + "app.services.zep_tools.fetch_all_nodes", + lambda client, graph_id, locale=None: [ + type( + "Node", + (), + { + "uuid": "node-short", + "name": "特朗普", + "labels": ["Entity", "PublicFigure"], + "summary": "Former president and recurring political actor.", + "attributes": {"country": "US"}, + }, + )(), + type( + "Node", + (), + { + "uuid": "node-long", + "name": "美国总统特朗普", + "labels": ["Entity", "PublicFigure"], + "summary": "", + "attributes": {}, + }, + )(), + ], + ) + + result = service.get_node_detail("node-long", graph_id="graph-1") + + assert result is not None + assert result.uuid == "node-short" + assert result.name == "特朗普" + assert result.summary == "Former president and recurring political actor." + assert result.attributes == {"country": "US"} + assert result.alias_names == ["特朗普", "美国总统特朗普"] + + +def test_get_all_edges_collapses_alias_linked_duplicates(monkeypatch): + service = _make_service() + service._locale = lambda: "en" + service.client = object() + + monkeypatch.setattr( + "app.services.zep_tools.fetch_all_nodes", + lambda client, graph_id, locale=None: [ + type( + "Node", + (), + { + "uuid": "node-short", + "name": "特朗普", + "labels": ["Entity", "PublicFigure"], + "summary": "", + "attributes": {}, + }, + )(), + type( + "Node", + (), + { + "uuid": "node-long", + "name": "美国总统特朗普", + "labels": ["Entity", "PublicFigure"], + "summary": "Former president and recurring political actor.", + "attributes": {"country": "US"}, + }, + )(), + type( + "Node", + (), + { + "uuid": "node-wh", + "name": "白宫", + "labels": ["Entity", "Organization"], + "summary": "Executive residence and workplace.", + "attributes": {}, + }, + )(), + ], + ) + monkeypatch.setattr( + "app.services.zep_tools.fetch_all_edges", + lambda client, graph_id, locale=None: [ + type( + "Edge", + (), + { + "uuid": "edge-1", + "name": "MENTIONS", + "fact": "特朗普 criticized the proposal.", + "source_node_uuid": "node-short", + "target_node_uuid": "node-wh", + "created_at": None, + "valid_at": None, + "invalid_at": None, + "expired_at": None, + }, + )(), + type( + "Edge", + (), + { + "uuid": "edge-2", + "name": "MENTIONS", + "fact": "特朗普 criticized the proposal.", + "source_node_uuid": "node-long", + "target_node_uuid": "node-wh", + "created_at": None, + "valid_at": None, + "invalid_at": None, + "expired_at": None, + }, + )(), + ], + ) + + result = service.get_all_edges("graph-1") + + assert len(result) == 1 + assert result[0].source_node_uuid == "node-short" + assert result[0].source_node_name == "特朗普" + assert result[0].target_node_uuid == "node-wh" + assert result[0].target_node_name == "白宫" diff --git a/backend/tests/test_zep_tools_i18n.py b/backend/tests/test_zep_tools_i18n.py new file mode 100644 index 00000000..b658ac19 --- /dev/null +++ b/backend/tests/test_zep_tools_i18n.py @@ -0,0 +1,734 @@ +from __future__ import annotations + +import sys +from pathlib import Path +from types import ModuleType + +from flask import Flask + +fake_zep_cloud = ModuleType("zep_cloud") +fake_zep_client = ModuleType("zep_cloud.client") +fake_zep_client.Zep = object +fake_zep_cloud.client = fake_zep_client +fake_zep_cloud.__getattr__ = lambda name: object +sys.modules.setdefault("zep_cloud", fake_zep_cloud) +sys.modules.setdefault("zep_cloud.client", fake_zep_client) + +from app.services import zep_tools as zep_tools_module +from app.services.zep_tools import ( + AgentInterview, + EdgeInfo, + InsightForgeResult, + NodeInfo, + PanoramaResult, + SearchResult, + ZepToolsService, +) + + +def _make_service() -> ZepToolsService: + return ZepToolsService.__new__(ZepToolsService) + + +class FakeLogger: + def __init__(self) -> None: + self.messages = [] + + def debug(self, message): + self.messages.append(("debug", str(message))) + + def info(self, message): + self.messages.append(("info", str(message))) + + def warning(self, message): + self.messages.append(("warning", str(message))) + + def error(self, message): + self.messages.append(("error", str(message))) + + +def test_interview_agents_localizes_missing_profiles_summary_in_english(): + app = Flask(__name__) + service = _make_service() + service._load_agent_profiles = lambda simulation_id: [] + + with app.test_request_context(headers={"X-Locale": "en"}): + result = service.interview_agents("sim_123", "Understand the reaction") + + assert result.summary == "No interviewable agent profiles were found" + + +def test_search_graph_localizes_fallback_logs_in_english(monkeypatch): + app = Flask(__name__) + service = _make_service() + service.MAX_RETRIES = 1 + service.RETRY_DELAY = 0 + + class FakeGraphAPI: + def search(self, **kwargs): + raise RuntimeError("search unavailable") + + class FakeClient: + graph = FakeGraphAPI() + + fake_logger = FakeLogger() + service.client = FakeClient() + service.get_all_edges = lambda graph_id: [ + EdgeInfo( + uuid="edge-1", + name="influences", + fact="Alice influences Bob", + source_node_uuid="node-1", + target_node_uuid="node-2", + locale="en", + ) + ] + service.get_all_nodes = lambda graph_id: [] + monkeypatch.setattr(zep_tools_module, "logger", fake_logger) + + with app.test_request_context(headers={"X-Locale": "en"}): + result = service.search_graph("graph-1", "Alice") + + assert result.total_count == 1 + assert any("Graph search: graph_id=graph-1" in message for _, message in fake_logger.messages) + assert any("falling back to local search" in message for _, message in fake_logger.messages) + assert any("Local search completed: found 1 relevant facts" in message for _, message in fake_logger.messages) + assert all("图谱搜索" not in message for _, message in fake_logger.messages) + + +def test_graph_introspection_logs_are_localized_in_english(monkeypatch): + app = Flask(__name__) + service = _make_service() + fake_logger = FakeLogger() + + class FakeNode: + def __init__(self, uuid_, name): + self.uuid_ = uuid_ + self.name = name + self.labels = ["Entity", "Analyst"] + self.summary = "Tracks sentiment shifts." + self.attributes = {} + + class FakeEdge: + def __init__(self): + self.uuid_ = "edge-1" + self.name = "influences" + self.fact = "Alice influences Bob" + self.source_node_uuid = "node-1" + self.target_node_uuid = "node-2" + self.created_at = None + self.valid_at = None + self.invalid_at = None + self.expired_at = None + + class FakeNodeAPI: + @staticmethod + def get(uuid_): + return FakeNode(uuid_, "Alice") + + class FakeGraphAPI: + node = FakeNodeAPI() + + class FakeClient: + graph = FakeGraphAPI() + + service.client = FakeClient() + monkeypatch.setattr(zep_tools_module, "logger", fake_logger) + monkeypatch.setattr(zep_tools_module, "fetch_all_nodes", lambda client, graph_id: [FakeNode("node-1", "Alice")]) + monkeypatch.setattr(zep_tools_module, "fetch_all_edges", lambda client, graph_id: [FakeEdge()]) + + with app.test_request_context(headers={"X-Locale": "en"}): + nodes = service.get_all_nodes("graph-1") + edges = service.get_all_edges("graph-1") + detail = service.get_node_detail("node-1") + related_edges = service.get_node_edges("graph-1", "node-1") + stats = service.get_graph_statistics("graph-1") + + assert len(nodes) == 1 + assert len(edges) == 1 + assert detail is not None + assert len(related_edges) == 1 + assert stats["total_nodes"] == 1 + assert any("Fetching all nodes for graph graph-1..." in message for _, message in fake_logger.messages) + assert any("Fetched 1 nodes" in message for _, message in fake_logger.messages) + assert any("Fetching node details: node-1..." in message for _, message in fake_logger.messages) + assert any("Fetching edges related to node node-1..." in message for _, message in fake_logger.messages) + assert any("Found 1 edges related to the node" in message for _, message in fake_logger.messages) + assert any("Fetching graph statistics for graph-1..." in message for _, message in fake_logger.messages) + assert all("获取图谱" not in message for _, message in fake_logger.messages) + assert all("获取节点" not in message for _, message in fake_logger.messages) + + +def test_panorama_quicksearch_and_insightforge_logs_are_localized_in_english(monkeypatch): + app = Flask(__name__) + service = _make_service() + fake_logger = FakeLogger() + + monkeypatch.setattr(zep_tools_module, "logger", fake_logger) + service.search_graph = lambda **kwargs: SearchResult( + facts=["Alice influences Bob"], + edges=[ + { + "name": "influences", + "fact": "Alice influences Bob", + "source_node_uuid": "node-1", + "target_node_uuid": "node-2", + } + ], + nodes=[], + query=kwargs["query"], + total_count=1, + locale="en", + ) + service.get_all_nodes = lambda graph_id: [ + NodeInfo( + uuid="node-1", + name="Alice", + labels=["Entity", "Analyst"], + summary="Tracks sentiment shifts.", + attributes={}, + locale="en", + ), + NodeInfo( + uuid="node-2", + name="Bob", + labels=["Entity", "Citizen"], + summary="Responds to policy changes.", + attributes={}, + locale="en", + ), + ] + service.get_all_edges = lambda graph_id, include_temporal=True: [ + EdgeInfo( + uuid="edge-1", + name="influences", + fact="Alice influences Bob", + source_node_uuid="node-1", + target_node_uuid="node-2", + source_node_name="Alice", + target_node_name="Bob", + locale="en", + ) + ] + service._generate_sub_queries = lambda **kwargs: ["Who influenced the discussion?"] + service.get_node_detail = lambda uuid, graph_id=None: NodeInfo( + uuid=uuid, + name="Alice" if uuid == "node-1" else "Bob", + labels=["Entity", "Analyst" if uuid == "node-1" else "Citizen"], + summary="Context", + attributes={}, + locale="en", + ) + + with app.test_request_context(headers={"X-Locale": "en"}): + quick = service.quick_search("graph-1", "Alice") + panorama = service.panorama_search("graph-1", "Alice") + insight = service.insight_forge("graph-1", "Who influenced the discussion?", "Track policy sentiment") + + assert quick.total_count == 1 + assert panorama.active_count == 1 + assert insight.total_facts == 1 + assert any("QuickSearch: Alice..." in message for _, message in fake_logger.messages) + assert any("QuickSearch completed: 1 results" in message for _, message in fake_logger.messages) + assert any("PanoramaSearch overview: Alice..." in message for _, message in fake_logger.messages) + assert any("PanoramaSearch completed: 1 active facts, 0 historical facts" in message for _, message in fake_logger.messages) + assert any("InsightForge deep analysis: Who influenced the discussion?..." in message for _, message in fake_logger.messages) + assert any("Generated 1 sub-queries" in message for _, message in fake_logger.messages) + assert any("InsightForge completed: 1 facts, 2 entities, 1 relationships" in message for _, message in fake_logger.messages) + assert all("深度洞察检索" not in message for _, message in fake_logger.messages) + assert all("条有效" not in message for _, message in fake_logger.messages) + + +def test_interview_agents_localizes_missing_profile_logs_in_english(monkeypatch): + app = Flask(__name__) + service = _make_service() + service._load_agent_profiles = lambda simulation_id: [] + fake_logger = FakeLogger() + monkeypatch.setattr(zep_tools_module, "logger", fake_logger) + + with app.test_request_context(headers={"X-Locale": "en"}): + result = service.interview_agents("sim_123", "Understand the reaction") + + assert result.summary == "No interviewable agent profiles were found" + assert any("InterviewAgents deep interview (live API)" in message for _, message in fake_logger.messages) + assert any("No agent profile files were found for simulation sim_123" in message for _, message in fake_logger.messages) + assert all("未找到模拟" not in message for _, message in fake_logger.messages) + + +def test_interview_agents_localizes_english_placeholders_and_text(monkeypatch): + app = Flask(__name__) + service = _make_service() + service._load_agent_profiles = lambda simulation_id: [ + { + "username": "alice", + "realname": "Alice", + "profession": "Analyst", + "bio": "Tracks policy sentiment shifts.", + } + ] + service._select_agents_for_interview = lambda **kwargs: ( + [service._load_agent_profiles("sim_123")[0]], + [0], + "Selected for domain relevance.", + ) + service._generate_interview_questions = lambda **kwargs: ["What changed after the announcement?"] + service._generate_interview_summary = lambda **kwargs: "The reaction was mixed but informed." + + monkeypatch.setattr( + "app.services.simulation_runner.SimulationRunner.interview_agents_batch", + lambda **kwargs: { + "success": True, + "interviews_count": 1, + "result": { + "results": { + "twitter_0": {"response": "People became more cautious after the update."}, + "reddit_0": {"response": ""}, + } + }, + }, + ) + + with app.test_request_context(headers={"X-Locale": "en"}): + result = service.interview_agents("sim_123", "Understand the reaction") + + assert result.interviews[0].response == ( + "[Twitter answer]\n" + "People became more cautious after the update.\n\n" + "[Reddit answer]\n" + "(no reply received from this platform)" + ) + assert result.interviews[0].key_quotes == [ + "People became more cautious after the update." + ] + + rendered = result.to_text() + assert "## In-Depth Interview Report" in rendered + assert "**Interview topic:** Understand the reaction" in rendered + assert "_Bio: Tracks policy sentiment shifts._" in rendered + assert '**Key quotes:**' in rendered + assert '> "People became more cautious after the update."' in rendered + + +def test_interview_agents_localizes_api_failure_summary_in_english(monkeypatch): + app = Flask(__name__) + service = _make_service() + service._load_agent_profiles = lambda simulation_id: [{"username": "alice", "bio": ""}] + service._select_agents_for_interview = lambda **kwargs: ([{"username": "alice", "bio": ""}], [0], "") + service._generate_interview_questions = lambda **kwargs: ["What happened?"] + + monkeypatch.setattr( + "app.services.simulation_runner.SimulationRunner.interview_agents_batch", + lambda **kwargs: {"success": False, "error": "env offline"}, + ) + + with app.test_request_context(headers={"X-Locale": "en"}): + result = service.interview_agents("sim_123", "Understand the reaction") + + assert ( + result.summary + == "Interview API call failed: env offline. Check the OASIS simulation environment status." + ) + + +def test_interview_agents_localizes_live_api_prompt_prefix_in_english(monkeypatch): + app = Flask(__name__) + service = _make_service() + service._load_agent_profiles = lambda simulation_id: [{"username": "alice", "bio": ""}] + service._select_agents_for_interview = lambda **kwargs: ([{"username": "alice", "bio": ""}], [0], "") + service._generate_interview_questions = lambda **kwargs: ["What happened?"] + captured = {} + monkeypatch.setattr(zep_tools_module.Config, "INTERVIEW_BATCH_TIMEOUT_SECONDS", 321.0) + + def fake_batch(**kwargs): + captured["prompt"] = kwargs["interviews"][0]["prompt"] + captured["timeout"] = kwargs["timeout"] + return { + "success": True, + "interviews_count": 1, + "result": {"results": {"twitter_0": {"response": "It changed quickly."}, "reddit_0": {"response": ""}}}, + } + + monkeypatch.setattr( + "app.services.simulation_runner.SimulationRunner.interview_agents_batch", + fake_batch, + ) + + with app.test_request_context(headers={"X-Locale": "en"}): + result = service.interview_agents("sim_123", "Understand the reaction") + + assert result.summary + assert captured["prompt"].startswith("You are being interviewed.") + assert "Response requirements:" in captured["prompt"] + assert "\"Question X:\"" in captured["prompt"] + assert "What happened?" in captured["prompt"] + assert captured["timeout"] == 321.0 + + +def test_tool_result_renderers_localize_deterministic_wrappers_in_english(): + search = SearchResult( + facts=["Alice joined the discussion."], + edges=[], + nodes=[], + query="Alice sentiment", + total_count=1, + locale="en", + ) + assert "Search query: Alice sentiment" in search.to_text() + assert "### Relevant facts:" in search.to_text() + + node = NodeInfo( + uuid="node-1", + name="Alice", + labels=["Entity", "Analyst"], + summary="Tracks sentiment shifts.", + attributes={}, + alias_names=["Alice", "Alice Chen"], + locale="en", + ) + assert ( + node.to_text() + == "Entity: Alice (Type: Analyst)\n" + "Summary: Tracks sentiment shifts.\n" + "Aliases: Alice, Alice Chen" + ) + + edge = EdgeInfo( + uuid="edge-1", + name="influences", + fact="Alice influences Bob", + source_node_uuid="node-1", + target_node_uuid="node-2", + source_node_name="Alice", + target_node_name="Bob", + valid_at="2026-03-01", + invalid_at=None, + expired_at="2026-03-10", + locale="en", + ) + rendered_edge = edge.to_text(include_temporal=True) + assert "Relationship: Alice --[influences]--> Bob" in rendered_edge + assert "Fact: Alice influences Bob" in rendered_edge + assert "Validity: 2026-03-01 - present" in rendered_edge + assert "(expired: 2026-03-10)" in rendered_edge + + insight = InsightForgeResult( + query="Who shaped the narrative?", + simulation_requirement="Track policy sentiment", + sub_queries=["Who posted first?"], + semantic_facts=["Alice posted first."], + entity_insights=[ + { + "name": "Alice", + "type": "Analyst", + "summary": "Tracks sentiment shifts.", + "related_facts": ["Alice posted first."], + } + ], + relationship_chains=["Alice --[influences]--> Bob"], + total_facts=1, + total_entities=1, + total_relationships=1, + locale="en", + ) + rendered_insight = insight.to_text() + assert "## Future prediction deep analysis" in rendered_insight + assert "### Analysis sub-questions" in rendered_insight + assert "### [Key facts] (quote these original statements in the report)" in rendered_insight + assert "### [Core entities]" in rendered_insight + assert "Summary: \"Tracks sentiment shifts.\"" in rendered_insight + assert "Related facts: 1" in rendered_insight + assert "### [Relationship chains]" in rendered_insight + + panorama = PanoramaResult( + query="Alice sentiment", + all_nodes=[node], + active_facts=["Alice joined the discussion."], + historical_facts=["[2026-02-01 - 2026-02-10] Alice ignored the topic."], + total_nodes=1, + total_edges=1, + active_count=1, + historical_count=1, + locale="en", + ) + rendered_panorama = panorama.to_text() + assert "## Panorama search results (future overview)" in rendered_panorama + assert "### Statistics" in rendered_panorama + assert "### [Current active facts] (original simulation output)" in rendered_panorama + assert "### [Historical/expired facts] (change timeline record)" in rendered_panorama + assert "### [Entities involved]" in rendered_panorama + + +def test_panorama_search_localizes_missing_historical_timestamps_in_english(): + app = Flask(__name__) + service = _make_service() + service._locale = lambda: "en" + service.get_all_nodes = lambda graph_id: [ + NodeInfo( + uuid="node-1", + name="Alice", + labels=["Entity", "Analyst"], + summary="Tracks sentiment shifts.", + attributes={}, + locale="en", + ) + ] + service.get_all_edges = lambda graph_id, include_temporal=True: [ + EdgeInfo( + uuid="edge-1", + name="influences", + fact="Alice ignored the topic.", + source_node_uuid="node-1", + target_node_uuid="node-2", + source_node_name="Alice", + target_node_name="Bob", + valid_at=None, + invalid_at=None, + expired_at="2026-03-10", + locale="en", + ) + ] + + with app.test_request_context(headers={"X-Locale": "en"}): + result = service.panorama_search("graph-1", "Alice") + + assert result.historical_facts == ["[Unknown - 2026-03-10] Alice ignored the topic."] + assert "未知" not in result.to_text() + + +def test_insight_forge_localizes_default_entity_type_in_english(): + app = Flask(__name__) + service = _make_service() + service._locale = lambda: "en" + service._generate_sub_queries = lambda **kwargs: ["Who shaped the narrative?"] + service.search_graph = lambda **kwargs: SearchResult( + facts=["Alice posted first."], + edges=[ + { + "source_node_uuid": "node-1", + "target_node_uuid": "node-2", + "name": "influences", + } + ], + nodes=[], + query=str(kwargs.get("query", "")), + total_count=1, + locale="en", + ) + service.get_node_detail = lambda uuid, graph_id=None: NodeInfo( + uuid=uuid, + name="Alice" if uuid == "node-1" else "Bob", + labels=["Entity"], + summary="Context", + attributes={}, + locale="en", + ) + + with app.test_request_context(headers={"X-Locale": "en"}): + result = service.insight_forge("graph-1", "Who shaped the narrative?", "Track policy sentiment") + + assert result.entity_insights[0]["type"] == "Entity" + assert "实体" not in result.to_text() + + +def test_select_agents_for_interview_localizes_prompts_fallback_reasoning_and_logs_in_english(monkeypatch): + app = Flask(__name__) + service = _make_service() + captured = {} + fake_logger = FakeLogger() + + class FakeLLM: + def chat_json(self, messages, temperature): + captured["messages"] = messages + raise RuntimeError("planner unavailable") + + service._llm_client = FakeLLM() + profiles = [{"username": "alice", "bio": "", "interested_topics": []}] + monkeypatch.setattr(zep_tools_module, "logger", fake_logger) + + with app.test_request_context(headers={"X-Locale": "en"}): + selected, indices, reasoning = service._select_agents_for_interview( + profiles=profiles, + interview_requirement="Understand the reaction", + simulation_requirement="", + max_agents=1, + ) + + assert selected == profiles + assert indices == [0] + assert reasoning == "Used the default selection strategy" + assert "You are an expert interview planner." in captured["messages"][0]["content"] + assert "Write the `reasoning` field in natural English" in captured["messages"][0]["content"] + assert "Simulation background:\nNot provided" in captured["messages"][1]["content"] + assert '"profession": "Unknown"' in captured["messages"][1]["content"] + assert any( + "LLM agent selection failed; using the default selection: planner unavailable" in message + for _, message in fake_logger.messages + ) + + +def test_generate_interview_questions_localizes_prompts_fallbacks_and_logs_in_english(monkeypatch): + app = Flask(__name__) + service = _make_service() + captured = {} + fake_logger = FakeLogger() + + class FakeLLM: + def chat_json(self, messages, temperature): + captured["messages"] = messages + raise RuntimeError("question generator unavailable") + + service._llm_client = FakeLLM() + monkeypatch.setattr(zep_tools_module, "logger", fake_logger) + + with app.test_request_context(headers={"X-Locale": "en"}): + questions = service._generate_interview_questions( + interview_requirement="the reaction", + simulation_requirement="", + selected_agents=[{"profession": None}], + ) + + assert questions == [ + "What is your perspective on the reaction?", + "How does this affect you or the group you represent?", + "What should be changed or improved in response?", + ] + assert "You are a professional interviewer." in captured["messages"][0]["content"] + assert "Write every question in natural English" in captured["messages"][0]["content"] + assert "Simulation background: Not provided" in captured["messages"][1]["content"] + assert "Interviewee roles: Unknown" in captured["messages"][1]["content"] + assert any( + "Failed to generate interview questions: question generator unavailable" in message + for _, message in fake_logger.messages + ) + + +def test_generate_sub_queries_localizes_prompts_and_fallbacks_in_english(monkeypatch): + app = Flask(__name__) + service = _make_service() + captured = {} + fake_logger = FakeLogger() + + class FakeLLM: + def chat_json(self, messages, temperature): + captured["messages"] = messages + raise RuntimeError("sub-query planner unavailable") + + service._llm_client = FakeLLM() + monkeypatch.setattr(zep_tools_module, "logger", fake_logger) + + with app.test_request_context(headers={"X-Locale": "en"}): + result = service._generate_sub_queries( + query="How will the narrative change?", + simulation_requirement="Track public reaction over two weeks", + report_context="Recent posts show rising skepticism.", + max_queries=4, + ) + + assert result == [ + "How will the narrative change?", + "Who are the main actors related to How will the narrative change?", + "What are the causes and impacts of How will the narrative change?", + "How is How will the narrative change likely to evolve?", + ] + assert "You are an expert question analyst." in captured["messages"][0]["content"] + assert "Write every sub-question in natural English" in captured["messages"][0]["content"] + assert "Simulation background:\nTrack public reaction over two weeks" in captured["messages"][1]["content"] + assert "Report context:\nRecent posts show rising skepticism." in captured["messages"][1]["content"] + assert "Break the following question into 4 focused sub-questions" in captured["messages"][1]["content"] + assert "返回JSON格式" not in captured["messages"][0]["content"] + assert any( + "Failed to generate sub-queries: sub-query planner unavailable" in message + for _, message in fake_logger.messages + ) + + +def test_generate_interview_summary_localizes_empty_fallback_copy_and_logs_in_english(monkeypatch): + app = Flask(__name__) + service = _make_service() + fake_logger = FakeLogger() + + with app.test_request_context(headers={"X-Locale": "en"}): + assert service._generate_interview_summary([], "Understand the reaction") == "No interviews were completed" + + class FakeLLM: + def chat(self, messages, temperature, max_tokens): + raise RuntimeError("summary generator unavailable") + + service._llm_client = FakeLLM() + monkeypatch.setattr(zep_tools_module, "logger", fake_logger) + interviews = [ + AgentInterview( + agent_name="Alice", + agent_role="Analyst", + agent_bio="Tracks sentiment shifts.", + question="What changed?", + response="People became more cautious after the update.", + locale="en", + ) + ] + + with app.test_request_context(headers={"X-Locale": "en"}): + summary = service._generate_interview_summary(interviews, "Understand the reaction") + + assert summary == "Interviewed 1 participants, including: Alice" + assert any( + "Failed to generate the interview summary: summary generator unavailable" in message + for _, message in fake_logger.messages + ) + + +def test_load_agent_profiles_localizes_twitter_csv_unknown_profession_in_english(): + app = Flask(__name__) + service = _make_service() + simulation_id = "sim_csv_en" + sim_dir = Path(__file__).resolve().parents[1] / "uploads" / "simulations" / simulation_id + sim_dir.mkdir(parents=True) + try: + (sim_dir / "twitter_profiles.csv").write_text( + "name,username,description,user_char\nAlice,alice,Tracks sentiment shifts.,Detailed persona\n", + encoding="utf-8", + ) + + with app.test_request_context(headers={"X-Locale": "en"}): + profiles = service._load_agent_profiles(simulation_id) + + assert profiles[0]["profession"] == "Unknown" + finally: + csv_path = sim_dir / "twitter_profiles.csv" + if csv_path.exists(): + csv_path.unlink() + if sim_dir.exists(): + sim_dir.rmdir() + + +def test_generate_interview_summary_uses_english_wrappers_in_english_mode(): + app = Flask(__name__) + service = _make_service() + captured = {} + + class FakeLLM: + def chat(self, messages, temperature, max_tokens): + captured["messages"] = messages + return "Summary complete." + + service._llm_client = FakeLLM() + interviews = [ + AgentInterview( + agent_name="Alice", + agent_role="Unknown", + agent_bio="Tracks sentiment shifts.", + question="What changed?", + response="People became more cautious after the update.", + locale="en", + ) + ] + + with app.test_request_context(headers={"X-Locale": "en"}): + summary = service._generate_interview_summary(interviews, "Understand the reaction") + + assert summary == "Summary complete." + assert "Write the summary entirely in natural English" in captured["messages"][0]["content"] + assert "translate it into fluent English before quoting or summarizing it" in captured["messages"][0]["content"] + assert "Use standard English quotation marks when quoting interviewees directly" in captured["messages"][0]["content"] + assert "[Alice (Unknown)]" in captured["messages"][1]["content"] + assert "【Alice(Unknown)】" not in captured["messages"][1]["content"] diff --git a/backend/uv.lock b/backend/uv.lock index f1ce4b60..8646e74a 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -6,15 +6,6 @@ resolution-markers = [ "python_full_version < '3.12'", ] -[[package]] -name = "aiofiles" -version = "25.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, -] - [[package]] name = "annotated-types" version = "0.7.0" @@ -82,15 +73,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4c/1c/ff6546b6c12603d8dd1070aa3c3d273ad4c07f5771689a7b69a550e8c951/backcall-0.2.0-py2.py3-none-any.whl", hash = "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255", size = 11157, upload-time = "2020-06-09T15:11:30.87Z" }, ] -[[package]] -name = "backoff" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, -] - [[package]] name = "beautifulsoup4" version = "4.14.3" @@ -130,18 +112,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, ] -[[package]] -name = "cairocffi" -version = "1.7.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/70/c5/1a4dc131459e68a173cbdab5fad6b524f53f9c1ef7861b7698e998b837cc/cairocffi-1.7.1.tar.gz", hash = "sha256:2e48ee864884ec4a3a34bfa8c9ab9999f688286eb714a15a43ec9d068c36557b", size = 88096, upload-time = "2024-06-18T10:56:06.741Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/93/d8/ba13451aa6b745c49536e87b6bf8f629b950e84bd0e8308f7dc6883b67e2/cairocffi-1.7.1-py3-none-any.whl", hash = "sha256:9803a0e11f6c962f3b0ae2ec8ba6ae45e957a146a004697a1ac1bbf16b073b3f", size = 75611, upload-time = "2024-06-18T10:55:59.489Z" }, -] - [[package]] name = "camel-ai" version = "0.2.78" @@ -165,32 +135,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/81/0cfb1c0d9da589665e2eb4471887967e70bba428638c37fb4f6a78baf300/camel_ai-0.2.78-py3-none-any.whl", hash = "sha256:356624da13dfe0c55ef43dc509c18ce029f67fe3997966495a4ce9be931078d5", size = 1415578, upload-time = "2025-10-15T17:20:51.727Z" }, ] -[[package]] -name = "camel-oasis" -version = "0.2.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cairocffi" }, - { name = "camel-ai" }, - { name = "igraph" }, - { name = "neo4j" }, - { name = "openapi-spec-validator" }, - { name = "pandas" }, - { name = "pillow" }, - { name = "prance" }, - { name = "pre-commit" }, - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "requests-oauthlib" }, - { name = "sentence-transformers" }, - { name = "slack-sdk" }, - { name = "unstructured" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d9/6f/b36240380c65397f3e18829fce8ed0a1d19893b32d3596aa0902c7b3ad81/camel_oasis-0.2.5.tar.gz", hash = "sha256:f667dec86f9f7823d50f76b07733a34afc1427b923f1a673519206bb41a57f8c", size = 56966, upload-time = "2025-12-04T11:58:19.43Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/d0/6d62173602433937d0228b7809e2b244e09c11cfb1be9c6754ae3b20d887/camel_oasis-0.2.5-py3-none-any.whl", hash = "sha256:9ebd6ba8e331495ee56b25cc63982188b94125dde499e5e9c00398a1d47e606d", size = 75954, upload-time = "2025-12-04T11:58:18.363Z" }, -] - [[package]] name = "certifi" version = "2025.11.12" @@ -270,15 +214,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] -[[package]] -name = "cfgv" -version = "3.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, -] - [[package]] name = "chardet" version = "5.2.0" @@ -444,19 +379,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/c3/e90f4a4feae6410f914f8ebac129b9ae7a8c92eb60a638012dde42030a9d/cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c", size = 3438528, upload-time = "2025-10-15T23:18:26.227Z" }, ] -[[package]] -name = "dataclasses-json" -version = "0.6.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "marshmallow" }, - { name = "typing-inspect" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/64/a4/f71d9cf3a5ac257c993b5ca3f93df5f7fb395c725e7f1e6479d2514173c3/dataclasses_json-0.6.7.tar.gz", hash = "sha256:b6b3e528266ea45b9535223bc53ca645f5208833c29229e847b3f26a1cc55fc0", size = 32227, upload-time = "2024-06-09T16:20:19.103Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686, upload-time = "2024-06-09T16:20:16.715Z" }, -] - [[package]] name = "decorator" version = "5.2.1" @@ -475,15 +397,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, ] -[[package]] -name = "distlib" -version = "0.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, -] - [[package]] name = "distro" version = "1.9.0" @@ -508,15 +421,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, ] -[[package]] -name = "emoji" -version = "2.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/78/0d2db9382c92a163d7095fc08efff7800880f830a152cfced40161e7638d/emoji-2.15.0.tar.gz", hash = "sha256:eae4ab7d86456a70a00a985125a03263a5eac54cd55e51d7e184b1ed3b6757e4", size = 615483, upload-time = "2025-09-21T12:13:02.755Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/5e/4b5aaaabddfacfe36ba7768817bd1f71a7a810a43705e531f3ae4c690767/emoji-2.15.0-py3-none-any.whl", hash = "sha256:205296793d66a89d88af4688fa57fd6496732eb48917a87175a023c8138995eb", size = 608433, upload-time = "2025-09-21T12:13:01.197Z" }, -] - [[package]] name = "executing" version = "2.2.1" @@ -544,15 +448,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e3/7f/a1a97644e39e7316d850784c642093c99df1290a460df4ede27659056834/filelock-3.20.1-py3-none-any.whl", hash = "sha256:15d9e9a67306188a44baa72f569d2bfd803076269365fdea0934385da4dc361a", size = 16666, upload-time = "2025-12-15T23:54:26.874Z" }, ] -[[package]] -name = "filetype" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bb/29/745f7d30d47fe0f251d3ad3dc2978a23141917661998763bebb6da007eb1/filetype-1.2.0.tar.gz", hash = "sha256:66b56cd6474bf41d8c54660347d37afcc3f7d1970648de365c102ef77548aadb", size = 998020, upload-time = "2022-11-02T17:34:04.141Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/79/1b8fa1bb3568781e84c9200f951c735f3f157429f44be0495da55894d620/filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25", size = 19970, upload-time = "2022-11-02T17:34:01.425Z" }, -] - [[package]] name = "flask" version = "3.1.2" @@ -686,15 +581,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/bd/1a875e0d592d447cbc02805fd3fe0f497714d6a2583f59d14fa9ebad96eb/huggingface_hub-0.36.0-py3-none-any.whl", hash = "sha256:7bcc9ad17d5b3f07b57c78e79d527102d08313caa278a641993acddcb894548d", size = 566094, upload-time = "2025-10-23T12:11:59.557Z" }, ] -[[package]] -name = "identify" -version = "2.6.15" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, -] - [[package]] name = "idna" version = "3.11" @@ -899,21 +785,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, ] -[[package]] -name = "jsonschema-path" -version = "0.3.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pathable" }, - { name = "pyyaml" }, - { name = "referencing" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6e/45/41ebc679c2a4fced6a722f624c18d658dee42612b83ea24c1caf7c0eb3a8/jsonschema_path-0.3.4.tar.gz", hash = "sha256:8365356039f16cc65fddffafda5f58766e34bebab7d6d105616ab52bc4297001", size = 11159, upload-time = "2025-01-24T14:33:16.547Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/58/3485da8cb93d2f393bce453adeef16896751f14ba3e2024bc21dc9597646/jsonschema_path-0.3.4-py3-none-any.whl", hash = "sha256:f502191fdc2b22050f9a81c9237be9d27145b9001c55842bece5e94e382e52f8", size = 14810, upload-time = "2025-01-24T14:33:14.652Z" }, -] - [[package]] name = "jsonschema-specifications" version = "2025.9.1" @@ -964,156 +835,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780", size = 15884, upload-time = "2023-11-23T09:26:34.325Z" }, ] -[[package]] -name = "langdetect" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0e/72/a3add0e4eec4eb9e2569554f7c70f4a3c27712f40e3284d483e88094cc0e/langdetect-1.0.9.tar.gz", hash = "sha256:cbc1fef89f8d062739774bd51eda3da3274006b3661d199c2655f6b3f6d605a0", size = 981474, upload-time = "2021-05-07T07:54:13.562Z" } - -[[package]] -name = "lazy-object-proxy" -version = "1.12.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/08/a2/69df9c6ba6d316cfd81fe2381e464db3e6de5db45f8c43c6a23504abf8cb/lazy_object_proxy-1.12.0.tar.gz", hash = "sha256:1f5a462d92fd0cfb82f1fab28b51bfb209fabbe6aabf7f0d51472c0c124c0c61", size = 43681, upload-time = "2025-08-22T13:50:06.783Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/01/b3/4684b1e128a87821e485f5a901b179790e6b5bc02f89b7ee19c23be36ef3/lazy_object_proxy-1.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1cf69cd1a6c7fe2dbcc3edaa017cf010f4192e53796538cc7d5e1fedbfa4bcff", size = 26656, upload-time = "2025-08-22T13:42:30.605Z" }, - { url = "https://files.pythonhosted.org/packages/3a/03/1bdc21d9a6df9ff72d70b2ff17d8609321bea4b0d3cffd2cea92fb2ef738/lazy_object_proxy-1.12.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:efff4375a8c52f55a145dc8487a2108c2140f0bec4151ab4e1843e52eb9987ad", size = 68832, upload-time = "2025-08-22T13:42:31.675Z" }, - { url = "https://files.pythonhosted.org/packages/3d/4b/5788e5e8bd01d19af71e50077ab020bc5cce67e935066cd65e1215a09ff9/lazy_object_proxy-1.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1192e8c2f1031a6ff453ee40213afa01ba765b3dc861302cd91dbdb2e2660b00", size = 69148, upload-time = "2025-08-22T13:42:32.876Z" }, - { url = "https://files.pythonhosted.org/packages/79/0e/090bf070f7a0de44c61659cb7f74c2fe02309a77ca8c4b43adfe0b695f66/lazy_object_proxy-1.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3605b632e82a1cbc32a1e5034278a64db555b3496e0795723ee697006b980508", size = 67800, upload-time = "2025-08-22T13:42:34.054Z" }, - { url = "https://files.pythonhosted.org/packages/cf/d2/b320325adbb2d119156f7c506a5fbfa37fcab15c26d13cf789a90a6de04e/lazy_object_proxy-1.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a61095f5d9d1a743e1e20ec6d6db6c2ca511961777257ebd9b288951b23b44fa", size = 68085, upload-time = "2025-08-22T13:42:35.197Z" }, - { url = "https://files.pythonhosted.org/packages/6a/48/4b718c937004bf71cd82af3713874656bcb8d0cc78600bf33bb9619adc6c/lazy_object_proxy-1.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:997b1d6e10ecc6fb6fe0f2c959791ae59599f41da61d652f6c903d1ee58b7370", size = 26535, upload-time = "2025-08-22T13:42:36.521Z" }, - { url = "https://files.pythonhosted.org/packages/0d/1b/b5f5bd6bda26f1e15cd3232b223892e4498e34ec70a7f4f11c401ac969f1/lazy_object_proxy-1.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ee0d6027b760a11cc18281e702c0309dd92da458a74b4c15025d7fc490deede", size = 26746, upload-time = "2025-08-22T13:42:37.572Z" }, - { url = "https://files.pythonhosted.org/packages/55/64/314889b618075c2bfc19293ffa9153ce880ac6153aacfd0a52fcabf21a66/lazy_object_proxy-1.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4ab2c584e3cc8be0dfca422e05ad30a9abe3555ce63e9ab7a559f62f8dbc6ff9", size = 71457, upload-time = "2025-08-22T13:42:38.743Z" }, - { url = "https://files.pythonhosted.org/packages/11/53/857fc2827fc1e13fbdfc0ba2629a7d2579645a06192d5461809540b78913/lazy_object_proxy-1.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14e348185adbd03ec17d051e169ec45686dcd840a3779c9d4c10aabe2ca6e1c0", size = 71036, upload-time = "2025-08-22T13:42:40.184Z" }, - { url = "https://files.pythonhosted.org/packages/2b/24/e581ffed864cd33c1b445b5763d617448ebb880f48675fc9de0471a95cbc/lazy_object_proxy-1.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c4fcbe74fb85df8ba7825fa05eddca764138da752904b378f0ae5ab33a36c308", size = 69329, upload-time = "2025-08-22T13:42:41.311Z" }, - { url = "https://files.pythonhosted.org/packages/78/be/15f8f5a0b0b2e668e756a152257d26370132c97f2f1943329b08f057eff0/lazy_object_proxy-1.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:563d2ec8e4d4b68ee7848c5ab4d6057a6d703cb7963b342968bb8758dda33a23", size = 70690, upload-time = "2025-08-22T13:42:42.51Z" }, - { url = "https://files.pythonhosted.org/packages/5d/aa/f02be9bbfb270e13ee608c2b28b8771f20a5f64356c6d9317b20043c6129/lazy_object_proxy-1.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:53c7fd99eb156bbb82cbc5d5188891d8fdd805ba6c1e3b92b90092da2a837073", size = 26563, upload-time = "2025-08-22T13:42:43.685Z" }, - { url = "https://files.pythonhosted.org/packages/f4/26/b74c791008841f8ad896c7f293415136c66cc27e7c7577de4ee68040c110/lazy_object_proxy-1.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:86fd61cb2ba249b9f436d789d1356deae69ad3231dc3c0f17293ac535162672e", size = 26745, upload-time = "2025-08-22T13:42:44.982Z" }, - { url = "https://files.pythonhosted.org/packages/9b/52/641870d309e5d1fb1ea7d462a818ca727e43bfa431d8c34b173eb090348c/lazy_object_proxy-1.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:81d1852fb30fab81696f93db1b1e55a5d1ff7940838191062f5f56987d5fcc3e", size = 71537, upload-time = "2025-08-22T13:42:46.141Z" }, - { url = "https://files.pythonhosted.org/packages/47/b6/919118e99d51c5e76e8bf5a27df406884921c0acf2c7b8a3b38d847ab3e9/lazy_object_proxy-1.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9045646d83f6c2664c1330904b245ae2371b5c57a3195e4028aedc9f999655", size = 71141, upload-time = "2025-08-22T13:42:47.375Z" }, - { url = "https://files.pythonhosted.org/packages/e5/47/1d20e626567b41de085cf4d4fb3661a56c159feaa73c825917b3b4d4f806/lazy_object_proxy-1.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:67f07ab742f1adfb3966c40f630baaa7902be4222a17941f3d85fd1dae5565ff", size = 69449, upload-time = "2025-08-22T13:42:48.49Z" }, - { url = "https://files.pythonhosted.org/packages/58/8d/25c20ff1a1a8426d9af2d0b6f29f6388005fc8cd10d6ee71f48bff86fdd0/lazy_object_proxy-1.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75ba769017b944fcacbf6a80c18b2761a1795b03f8899acdad1f1c39db4409be", size = 70744, upload-time = "2025-08-22T13:42:49.608Z" }, - { url = "https://files.pythonhosted.org/packages/c0/67/8ec9abe15c4f8a4bcc6e65160a2c667240d025cbb6591b879bea55625263/lazy_object_proxy-1.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:7b22c2bbfb155706b928ac4d74c1a63ac8552a55ba7fff4445155523ea4067e1", size = 26568, upload-time = "2025-08-22T13:42:57.719Z" }, - { url = "https://files.pythonhosted.org/packages/23/12/cd2235463f3469fd6c62d41d92b7f120e8134f76e52421413a0ad16d493e/lazy_object_proxy-1.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4a79b909aa16bde8ae606f06e6bbc9d3219d2e57fb3e0076e17879072b742c65", size = 27391, upload-time = "2025-08-22T13:42:50.62Z" }, - { url = "https://files.pythonhosted.org/packages/60/9e/f1c53e39bbebad2e8609c67d0830cc275f694d0ea23d78e8f6db526c12d3/lazy_object_proxy-1.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:338ab2f132276203e404951205fe80c3fd59429b3a724e7b662b2eb539bb1be9", size = 80552, upload-time = "2025-08-22T13:42:51.731Z" }, - { url = "https://files.pythonhosted.org/packages/4c/b6/6c513693448dcb317d9d8c91d91f47addc09553613379e504435b4cc8b3e/lazy_object_proxy-1.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c40b3c9faee2e32bfce0df4ae63f4e73529766893258eca78548bac801c8f66", size = 82857, upload-time = "2025-08-22T13:42:53.225Z" }, - { url = "https://files.pythonhosted.org/packages/12/1c/d9c4aaa4c75da11eb7c22c43d7c90a53b4fca0e27784a5ab207768debea7/lazy_object_proxy-1.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:717484c309df78cedf48396e420fa57fc8a2b1f06ea889df7248fdd156e58847", size = 80833, upload-time = "2025-08-22T13:42:54.391Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ae/29117275aac7d7d78ae4f5a4787f36ff33262499d486ac0bf3e0b97889f6/lazy_object_proxy-1.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a6b7ea5ea1ffe15059eb44bcbcb258f97bcb40e139b88152c40d07b1a1dfc9ac", size = 79516, upload-time = "2025-08-22T13:42:55.812Z" }, - { url = "https://files.pythonhosted.org/packages/19/40/b4e48b2c38c69392ae702ae7afa7b6551e0ca5d38263198b7c79de8b3bdf/lazy_object_proxy-1.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:08c465fb5cd23527512f9bd7b4c7ba6cec33e28aad36fbbe46bf7b858f9f3f7f", size = 27656, upload-time = "2025-08-22T13:42:56.793Z" }, - { url = "https://files.pythonhosted.org/packages/ef/3a/277857b51ae419a1574557c0b12e0d06bf327b758ba94cafc664cb1e2f66/lazy_object_proxy-1.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c9defba70ab943f1df98a656247966d7729da2fe9c2d5d85346464bf320820a3", size = 26582, upload-time = "2025-08-22T13:49:49.366Z" }, - { url = "https://files.pythonhosted.org/packages/1a/b6/c5e0fa43535bb9c87880e0ba037cdb1c50e01850b0831e80eb4f4762f270/lazy_object_proxy-1.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6763941dbf97eea6b90f5b06eb4da9418cc088fce0e3883f5816090f9afcde4a", size = 71059, upload-time = "2025-08-22T13:49:50.488Z" }, - { url = "https://files.pythonhosted.org/packages/06/8a/7dcad19c685963c652624702f1a968ff10220b16bfcc442257038216bf55/lazy_object_proxy-1.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fdc70d81235fc586b9e3d1aeef7d1553259b62ecaae9db2167a5d2550dcc391a", size = 71034, upload-time = "2025-08-22T13:49:54.224Z" }, - { url = "https://files.pythonhosted.org/packages/12/ac/34cbfb433a10e28c7fd830f91c5a348462ba748413cbb950c7f259e67aa7/lazy_object_proxy-1.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0a83c6f7a6b2bfc11ef3ed67f8cbe99f8ff500b05655d8e7df9aab993a6abc95", size = 69529, upload-time = "2025-08-22T13:49:55.29Z" }, - { url = "https://files.pythonhosted.org/packages/6f/6a/11ad7e349307c3ca4c0175db7a77d60ce42a41c60bcb11800aabd6a8acb8/lazy_object_proxy-1.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:256262384ebd2a77b023ad02fbcc9326282bcfd16484d5531154b02bc304f4c5", size = 70391, upload-time = "2025-08-22T13:49:56.35Z" }, - { url = "https://files.pythonhosted.org/packages/59/97/9b410ed8fbc6e79c1ee8b13f8777a80137d4bc189caf2c6202358e66192c/lazy_object_proxy-1.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7601ec171c7e8584f8ff3f4e440aa2eebf93e854f04639263875b8c2971f819f", size = 26988, upload-time = "2025-08-22T13:49:57.302Z" }, - { url = "https://files.pythonhosted.org/packages/41/a0/b91504515c1f9a299fc157967ffbd2f0321bce0516a3d5b89f6f4cad0355/lazy_object_proxy-1.12.0-pp39.pp310.pp311.graalpy311-none-any.whl", hash = "sha256:c3b2e0af1f7f77c4263759c4824316ce458fabe0fceadcd24ef8ca08b2d1e402", size = 15072, upload-time = "2025-08-22T13:50:05.498Z" }, -] - -[[package]] -name = "lxml" -version = "6.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/d5/becbe1e2569b474a23f0c672ead8a29ac50b2dc1d5b9de184831bda8d14c/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607", size = 8634365, upload-time = "2025-09-22T04:00:45.672Z" }, - { url = "https://files.pythonhosted.org/packages/28/66/1ced58f12e804644426b85d0bb8a4478ca77bc1761455da310505f1a3526/lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938", size = 4650793, upload-time = "2025-09-22T04:00:47.783Z" }, - { url = "https://files.pythonhosted.org/packages/11/84/549098ffea39dfd167e3f174b4ce983d0eed61f9d8d25b7bf2a57c3247fc/lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d", size = 4944362, upload-time = "2025-09-22T04:00:49.845Z" }, - { url = "https://files.pythonhosted.org/packages/ac/bd/f207f16abf9749d2037453d56b643a7471d8fde855a231a12d1e095c4f01/lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438", size = 5083152, upload-time = "2025-09-22T04:00:51.709Z" }, - { url = "https://files.pythonhosted.org/packages/15/ae/bd813e87d8941d52ad5b65071b1affb48da01c4ed3c9c99e40abb266fbff/lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964", size = 5023539, upload-time = "2025-09-22T04:00:53.593Z" }, - { url = "https://files.pythonhosted.org/packages/02/cd/9bfef16bd1d874fbe0cb51afb00329540f30a3283beb9f0780adbb7eec03/lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d", size = 5344853, upload-time = "2025-09-22T04:00:55.524Z" }, - { url = "https://files.pythonhosted.org/packages/b8/89/ea8f91594bc5dbb879734d35a6f2b0ad50605d7fb419de2b63d4211765cc/lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7", size = 5225133, upload-time = "2025-09-22T04:00:57.269Z" }, - { url = "https://files.pythonhosted.org/packages/b9/37/9c735274f5dbec726b2db99b98a43950395ba3d4a1043083dba2ad814170/lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178", size = 4677944, upload-time = "2025-09-22T04:00:59.052Z" }, - { url = "https://files.pythonhosted.org/packages/20/28/7dfe1ba3475d8bfca3878365075abe002e05d40dfaaeb7ec01b4c587d533/lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553", size = 5284535, upload-time = "2025-09-22T04:01:01.335Z" }, - { url = "https://files.pythonhosted.org/packages/e7/cf/5f14bc0de763498fc29510e3532bf2b4b3a1c1d5d0dff2e900c16ba021ef/lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb", size = 5067343, upload-time = "2025-09-22T04:01:03.13Z" }, - { url = "https://files.pythonhosted.org/packages/1c/b0/bb8275ab5472f32b28cfbbcc6db7c9d092482d3439ca279d8d6fa02f7025/lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a", size = 4725419, upload-time = "2025-09-22T04:01:05.013Z" }, - { url = "https://files.pythonhosted.org/packages/25/4c/7c222753bc72edca3b99dbadba1b064209bc8ed4ad448af990e60dcce462/lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c", size = 5275008, upload-time = "2025-09-22T04:01:07.327Z" }, - { url = "https://files.pythonhosted.org/packages/6c/8c/478a0dc6b6ed661451379447cdbec77c05741a75736d97e5b2b729687828/lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7", size = 5248906, upload-time = "2025-09-22T04:01:09.452Z" }, - { url = "https://files.pythonhosted.org/packages/2d/d9/5be3a6ab2784cdf9accb0703b65e1b64fcdd9311c9f007630c7db0cfcce1/lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46", size = 3610357, upload-time = "2025-09-22T04:01:11.102Z" }, - { url = "https://files.pythonhosted.org/packages/e2/7d/ca6fb13349b473d5732fb0ee3eec8f6c80fc0688e76b7d79c1008481bf1f/lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078", size = 4036583, upload-time = "2025-09-22T04:01:12.766Z" }, - { url = "https://files.pythonhosted.org/packages/ab/a2/51363b5ecd3eab46563645f3a2c3836a2fc67d01a1b87c5017040f39f567/lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285", size = 3680591, upload-time = "2025-09-22T04:01:14.874Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" }, - { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" }, - { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" }, - { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" }, - { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" }, - { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" }, - { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" }, - { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" }, - { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" }, - { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" }, - { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" }, - { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" }, - { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" }, - { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" }, - { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" }, - { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" }, - { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" }, - { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" }, - { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" }, - { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" }, - { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" }, - { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" }, - { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" }, - { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" }, - { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" }, - { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" }, - { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" }, - { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" }, - { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" }, - { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" }, - { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" }, - { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" }, - { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" }, - { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" }, - { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" }, - { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" }, - { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" }, - { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" }, - { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" }, - { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" }, - { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" }, - { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" }, - { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" }, - { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" }, - { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" }, - { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" }, - { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" }, - { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" }, - { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" }, - { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" }, - { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" }, - { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" }, - { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" }, - { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" }, - { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" }, - { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" }, - { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" }, - { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" }, - { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" }, - { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" }, - { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" }, - { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" }, - { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" }, - { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" }, - { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" }, - { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" }, - { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" }, - { url = "https://files.pythonhosted.org/packages/0b/11/29d08bc103a62c0eba8016e7ed5aeebbf1e4312e83b0b1648dd203b0e87d/lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700", size = 3949829, upload-time = "2025-09-22T04:04:45.608Z" }, - { url = "https://files.pythonhosted.org/packages/12/b3/52ab9a3b31e5ab8238da241baa19eec44d2ab426532441ee607165aebb52/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee", size = 4226277, upload-time = "2025-09-22T04:04:47.754Z" }, - { url = "https://files.pythonhosted.org/packages/a0/33/1eaf780c1baad88224611df13b1c2a9dfa460b526cacfe769103ff50d845/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f", size = 4330433, upload-time = "2025-09-22T04:04:49.907Z" }, - { url = "https://files.pythonhosted.org/packages/7a/c1/27428a2ff348e994ab4f8777d3a0ad510b6b92d37718e5887d2da99952a2/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9", size = 4272119, upload-time = "2025-09-22T04:04:51.801Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d0/3020fa12bcec4ab62f97aab026d57c2f0cfd480a558758d9ca233bb6a79d/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a", size = 4417314, upload-time = "2025-09-22T04:04:55.024Z" }, - { url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" }, -] - [[package]] name = "markupsafe" version = "3.0.3" @@ -1188,18 +909,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] -[[package]] -name = "marshmallow" -version = "3.26.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ab/5e/5e53d26b42ab75491cda89b871dab9e97c840bf12c63ec58a1919710cd06/marshmallow-3.26.1.tar.gz", hash = "sha256:e6d8affb6cb61d39d26402096dc0aee12d5a26d490a121f118d2e81dc0719dc6", size = 221825, upload-time = "2025-02-03T15:32:25.093Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/34/75/51952c7b2d3873b44a0028b1bd26a25078c18f92f256608e8d1dc61b39fd/marshmallow-3.26.1-py3-none-any.whl", hash = "sha256:3350409f20a70a7e4e11a27661187b77cdcaeb20abca41c1454fe33636bea09c", size = 50878, upload-time = "2025-02-03T15:32:22.295Z" }, -] - [[package]] name = "matplotlib-inline" version = "0.2.1" @@ -1242,8 +951,6 @@ name = "mirofish-backend" version = "0.1.0" source = { editable = "." } dependencies = [ - { name = "camel-ai" }, - { name = "camel-oasis" }, { name = "chardet" }, { name = "charset-normalizer" }, { name = "flask" }, @@ -1261,6 +968,13 @@ dev = [ { name = "pytest" }, { name = "pytest-asyncio" }, ] +simulation = [ + { name = "camel-ai" }, + { name = "igraph" }, + { name = "neo4j" }, + { name = "pandas" }, + { name = "sentence-transformers" }, +] [package.dev-dependencies] dev = [ @@ -1270,22 +984,25 @@ dev = [ [package.metadata] requires-dist = [ - { name = "camel-ai", specifier = "==0.2.78" }, - { name = "camel-oasis", specifier = "==0.2.5" }, + { name = "camel-ai", marker = "extra == 'simulation'", specifier = "==0.2.78" }, { name = "chardet", specifier = ">=5.0.0" }, { name = "charset-normalizer", specifier = ">=3.0.0" }, { name = "flask", specifier = ">=3.0.0" }, { name = "flask-cors", specifier = ">=6.0.0" }, + { name = "igraph", marker = "extra == 'simulation'", specifier = "==0.11.6" }, + { name = "neo4j", marker = "extra == 'simulation'", specifier = "==5.23.0" }, { name = "openai", specifier = ">=1.0.0" }, + { name = "pandas", marker = "extra == 'simulation'", specifier = "==2.2.2" }, { name = "pipreqs", marker = "extra == 'dev'", specifier = ">=0.5.0" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "pymupdf", specifier = ">=1.24.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "sentence-transformers", marker = "extra == 'simulation'", specifier = "==3.0.0" }, { name = "zep-cloud", specifier = "==3.13.0" }, ] -provides-extras = ["dev"] +provides-extras = ["simulation", "dev"] [package.metadata.requires-dev] dev = [ @@ -1311,15 +1028,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, ] -[[package]] -name = "mypy-extensions" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, -] - [[package]] name = "nbclient" version = "0.10.2" @@ -1396,30 +1104,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, ] -[[package]] -name = "nltk" -version = "3.9.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "joblib" }, - { name = "regex" }, - { name = "tqdm" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f9/76/3a5e4312c19a028770f86fd7c058cf9f4ec4321c6cf7526bab998a5b683c/nltk-3.9.2.tar.gz", hash = "sha256:0f409e9b069ca4177c1903c3e843eef90c7e92992fa4931ae607da6de49e1419", size = 2887629, upload-time = "2025-10-01T07:19:23.764Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/90/81ac364ef94209c100e12579629dc92bf7a709a84af32f8c551b02c07e94/nltk-3.9.2-py3-none-any.whl", hash = "sha256:1e209d2b3009110635ed9709a67a1a3e33a10f799490fa71cf4bec218c11c88a", size = 1513404, upload-time = "2025-10-01T07:19:21.648Z" }, -] - -[[package]] -name = "nodeenv" -version = "1.9.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, -] - [[package]] name = "numpy" version = "2.3.5" @@ -1635,15 +1319,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f", size = 89954, upload-time = "2025-03-07T01:42:44.131Z" }, ] -[[package]] -name = "oauthlib" -version = "3.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, -] - [[package]] name = "openai" version = "1.109.1" @@ -1663,35 +1338,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1d/2a/7dd3d207ec669cacc1f186fd856a0f61dbc255d24f6fdc1a6715d6051b0f/openai-1.109.1-py3-none-any.whl", hash = "sha256:6bcaf57086cf59159b8e27447e4e7dd019db5d29a438072fbd49c290c7e65315", size = 948627, upload-time = "2025-09-24T13:00:50.754Z" }, ] -[[package]] -name = "openapi-schema-validator" -version = "0.6.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jsonschema" }, - { name = "jsonschema-specifications" }, - { name = "rfc3339-validator" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8b/f3/5507ad3325169347cd8ced61c232ff3df70e2b250c49f0fe140edb4973c6/openapi_schema_validator-0.6.3.tar.gz", hash = "sha256:f37bace4fc2a5d96692f4f8b31dc0f8d7400fd04f3a937798eaf880d425de6ee", size = 11550, upload-time = "2025-01-10T18:08:22.268Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/21/c6/ad0fba32775ae749016829dace42ed80f4407b171da41313d1a3a5f102e4/openapi_schema_validator-0.6.3-py3-none-any.whl", hash = "sha256:f3b9870f4e556b5a62a1c39da72a6b4b16f3ad9c73dc80084b1b11e74ba148a3", size = 8755, upload-time = "2025-01-10T18:08:19.758Z" }, -] - -[[package]] -name = "openapi-spec-validator" -version = "0.7.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jsonschema" }, - { name = "jsonschema-path" }, - { name = "lazy-object-proxy" }, - { name = "openapi-schema-validator" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/67/fe/21954ff978239dc29ebb313f5c87eeb4ec929b694b9667323086730998e2/openapi_spec_validator-0.7.1.tar.gz", hash = "sha256:8577b85a8268685da6f8aa30990b83b7960d4d1117e901d451b5d572605e5ec7", size = 37985, upload-time = "2023-10-13T11:43:40.53Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/4d/e744fff95aaf3aeafc968d5ba7297c8cda0d1ecb8e3acd21b25adae4d835/openapi_spec_validator-0.7.1-py3-none-any.whl", hash = "sha256:3c81825043f24ccbcd2f4b149b11e8231abce5ba84f37065e14ec947d8f4e959", size = 38998, upload-time = "2023-10-13T11:43:38.371Z" }, -] - [[package]] name = "packaging" version = "25.0" @@ -1747,15 +1393,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/32/f8e3c85d1d5250232a5d3477a2a28cc291968ff175caeadaf3cc19ce0e4a/parso-0.8.5-py2.py3-none-any.whl", hash = "sha256:646204b5ee239c396d040b90f9e272e9a8017c630092bf59980beb62fd033887", size = 106668, upload-time = "2025-08-23T15:15:25.663Z" }, ] -[[package]] -name = "pathable" -version = "0.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/67/93/8f2c2075b180c12c1e9f6a09d1a985bc2036906b13dff1d8917e395f2048/pathable-0.4.4.tar.gz", hash = "sha256:6905a3cd17804edfac7875b5f6c9142a218c7caef78693c2dbbbfbac186d88b2", size = 8124, upload-time = "2025-01-10T18:43:13.247Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/eb/b6260b31b1a96386c0a880edebe26f89669098acea8e0318bff6adb378fd/pathable-0.4.4-py3-none-any.whl", hash = "sha256:5ae9e94793b6ef5a4cbe0a7ce9dbbefc1eec38df253763fd0aeeacf2762dbbc2", size = 9592, upload-time = "2025-01-10T18:43:11.88Z" }, -] - [[package]] name = "pexpect" version = "4.9.0" @@ -1779,32 +1416,43 @@ wheels = [ [[package]] name = "pillow" -version = "10.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ef/43/c50c17c5f7d438e836c169e343695534c38c77f60e7c90389bd77981bc21/pillow-10.3.0.tar.gz", hash = "sha256:9d2455fbf44c914840c793e89aa82d0e1763a14253a000743719ae5946814b2d", size = 46572854, upload-time = "2024-04-01T12:19:40.048Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/51/e4b35e394b4e5ca24983e50361a1db3d7da05b1758074f9c4f5b4be4b22a/pillow-10.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:5f77cf66e96ae734717d341c145c5949c63180842a545c47a0ce7ae52ca83795", size = 3528936, upload-time = "2024-04-01T12:17:29.322Z" }, - { url = "https://files.pythonhosted.org/packages/00/5c/7633f291def20082bad31b844fe5ed07742aae8504e4cfe2f331ee727178/pillow-10.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e4b878386c4bf293578b48fc570b84ecfe477d3b77ba39a6e87150af77f40c57", size = 3352899, upload-time = "2024-04-01T12:17:31.843Z" }, - { url = "https://files.pythonhosted.org/packages/1d/29/abda81a079cccd1840b0b7b13ad67ffac87cc66395ae20973027280e9f9f/pillow-10.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdcbb4068117dfd9ce0138d068ac512843c52295ed996ae6dd1faf537b6dbc27", size = 4317733, upload-time = "2024-04-01T12:17:34.494Z" }, - { url = "https://files.pythonhosted.org/packages/77/cd/5205fb43a6000d424291b0525b8201004700d9a34e034517ac4dfdc6eed5/pillow-10.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9797a6c8fe16f25749b371c02e2ade0efb51155e767a971c61734b1bf6293994", size = 4429430, upload-time = "2024-04-01T12:17:37.112Z" }, - { url = "https://files.pythonhosted.org/packages/8c/bb/9e8d2b1b54235bd44139ee387beeb65ad9d8d755b5c01f817070c6dabea7/pillow-10.3.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:9e91179a242bbc99be65e139e30690e081fe6cb91a8e77faf4c409653de39451", size = 4341711, upload-time = "2024-04-01T12:17:39.151Z" }, - { url = "https://files.pythonhosted.org/packages/81/ff/ad3c942d865f9e45ce84eeb31795e6d4d94e1f1eea51026d5154028510d7/pillow-10.3.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:1b87bd9d81d179bd8ab871603bd80d8645729939f90b71e62914e816a76fc6bd", size = 4507469, upload-time = "2024-04-01T12:17:41.159Z" }, - { url = "https://files.pythonhosted.org/packages/ab/ab/30cd50a12d9afa2c412efcb8b37dd3f5f1da4bc77b984ddfbc776d96cf5b/pillow-10.3.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:81d09caa7b27ef4e61cb7d8fbf1714f5aec1c6b6c5270ee53504981e6e9121ad", size = 4533491, upload-time = "2024-04-01T12:17:43.813Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f0/07419615ffa852cded35dfa3337bf70788f232a3dfe622b97d5eb0c32674/pillow-10.3.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:048ad577748b9fa4a99a0548c64f2cb8d672d5bf2e643a739ac8faff1164238c", size = 4598334, upload-time = "2024-04-01T12:17:46.271Z" }, - { url = "https://files.pythonhosted.org/packages/9c/f3/6e923786f2b2d167d16783fc079c003aadbcedc4995f54e8429d91aabfc4/pillow-10.3.0-cp311-cp311-win32.whl", hash = "sha256:7161ec49ef0800947dc5570f86568a7bb36fa97dd09e9827dc02b718c5643f09", size = 2217293, upload-time = "2024-04-01T12:17:48.292Z" }, - { url = "https://files.pythonhosted.org/packages/0a/16/c83877524c47976f16703d2e05c363244bc1e60ab439e078b3cd046d07db/pillow-10.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8eb0908e954d093b02a543dc963984d6e99ad2b5e36503d8a0aaf040505f747d", size = 2531332, upload-time = "2024-04-01T12:17:50.844Z" }, - { url = "https://files.pythonhosted.org/packages/a8/3b/f64454549af90818774c3210b48987c3aeca5285787dbd69869d9a05b58f/pillow-10.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:4e6f7d1c414191c1199f8996d3f2282b9ebea0945693fb67392c75a3a320941f", size = 2229546, upload-time = "2024-04-01T12:17:53.237Z" }, - { url = "https://files.pythonhosted.org/packages/cc/5d/b7fcd38cba0f7706f64c1674fc9f018e4c64f791770598c44affadea7c2f/pillow-10.3.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:e46f38133e5a060d46bd630faa4d9fa0202377495df1f068a8299fd78c84de84", size = 3528535, upload-time = "2024-04-01T12:17:55.891Z" }, - { url = "https://files.pythonhosted.org/packages/5e/77/4cf407e7b033b4d8e5fcaac295b6e159cf1c70fa105d769f01ea2e1e5eca/pillow-10.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:50b8eae8f7334ec826d6eeffaeeb00e36b5e24aa0b9df322c247539714c6df19", size = 3352281, upload-time = "2024-04-01T12:17:58.527Z" }, - { url = "https://files.pythonhosted.org/packages/53/7b/4f7b153a776725a87797d744ea1c73b83ac0b723f5e379297605dee118eb/pillow-10.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d3bea1c75f8c53ee4d505c3e67d8c158ad4df0d83170605b50b64025917f338", size = 4321427, upload-time = "2024-04-01T12:18:00.809Z" }, - { url = "https://files.pythonhosted.org/packages/45/08/d2cc751b790e77464f8648aa707e2327d6da5d95cf236a532e99c2e7a499/pillow-10.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:19aeb96d43902f0a783946a0a87dbdad5c84c936025b8419da0a0cd7724356b1", size = 4435915, upload-time = "2024-04-01T12:18:03.084Z" }, - { url = "https://files.pythonhosted.org/packages/ef/97/f69d1932cf45bf5bd9fa1e2ae57bdf716524faa4fa9fb7dc62cdb1a19113/pillow-10.3.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:74d28c17412d9caa1066f7a31df8403ec23d5268ba46cd0ad2c50fb82ae40462", size = 4347392, upload-time = "2024-04-01T12:18:05.319Z" }, - { url = "https://files.pythonhosted.org/packages/c6/c1/3521ddb9c1f3ac106af3e4512a98c785b6ed8a39e0f778480b8a4d340165/pillow-10.3.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ff61bfd9253c3915e6d41c651d5f962da23eda633cf02262990094a18a55371a", size = 4514536, upload-time = "2024-04-01T12:18:08.039Z" }, - { url = "https://files.pythonhosted.org/packages/c0/6f/347c241904a6514e59515284b01ba6f61765269a0d1a19fd2e6cbe331c8a/pillow-10.3.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d886f5d353333b4771d21267c7ecc75b710f1a73d72d03ca06df49b09015a9ef", size = 4555987, upload-time = "2024-04-01T12:18:10.106Z" }, - { url = "https://files.pythonhosted.org/packages/c3/e2/3cc490c6b2e262713da82ce849c34bd8e6c31242afb53be8595d820b9877/pillow-10.3.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4b5ec25d8b17217d635f8935dbc1b9aa5907962fae29dff220f2659487891cd3", size = 4623526, upload-time = "2024-04-01T12:18:12.172Z" }, - { url = "https://files.pythonhosted.org/packages/c1/b3/0209f70fa29b383e7618e47db95712a45788dea03bb960601753262a2883/pillow-10.3.0-cp312-cp312-win32.whl", hash = "sha256:51243f1ed5161b9945011a7360e997729776f6e5d7005ba0c6879267d4c5139d", size = 2217547, upload-time = "2024-04-01T12:18:14.188Z" }, - { url = "https://files.pythonhosted.org/packages/d3/23/3927d888481ff7c44fdbca3bc2a2e97588c933db46723bf115201377c436/pillow-10.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:412444afb8c4c7a6cc11a47dade32982439925537e483be7c0ae0cf96c4f6a0b", size = 2531641, upload-time = "2024-04-01T12:18:16.081Z" }, - { url = "https://files.pythonhosted.org/packages/db/36/1ecaa0541d3a1b1362f937d386eeb1875847bfa06d5225f1b0e1588d1007/pillow-10.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:798232c92e7665fe82ac085f9d8e8ca98826f8e27859d9a96b41d519ecd2e49a", size = 2229746, upload-time = "2024-04-01T12:18:18.174Z" }, +version = "10.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/74/ad3d526f3bf7b6d3f408b73fde271ec69dfac8b81341a318ce825f2b3812/pillow-10.4.0.tar.gz", hash = "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06", size = 46555059, upload-time = "2024-07-01T09:48:43.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/62/c9449f9c3043c37f73e7487ec4ef0c03eb9c9afc91a92b977a67b3c0bbc5/pillow-10.4.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0a9ec697746f268507404647e531e92889890a087e03681a3606d9b920fbee3c", size = 3509265, upload-time = "2024-07-01T09:45:49.812Z" }, + { url = "https://files.pythonhosted.org/packages/f4/5f/491dafc7bbf5a3cc1845dc0430872e8096eb9e2b6f8161509d124594ec2d/pillow-10.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe91cb65544a1321e631e696759491ae04a2ea11d36715eca01ce07284738be", size = 3375655, upload-time = "2024-07-01T09:45:52.462Z" }, + { url = "https://files.pythonhosted.org/packages/73/d5/c4011a76f4207a3c151134cd22a1415741e42fa5ddecec7c0182887deb3d/pillow-10.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc6761a6efc781e6a1544206f22c80c3af4c8cf461206d46a1e6006e4429ff3", size = 4340304, upload-time = "2024-07-01T09:45:55.006Z" }, + { url = "https://files.pythonhosted.org/packages/ac/10/c67e20445a707f7a610699bba4fe050583b688d8cd2d202572b257f46600/pillow-10.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e84b6cc6a4a3d76c153a6b19270b3526a5a8ed6b09501d3af891daa2a9de7d6", size = 4452804, upload-time = "2024-07-01T09:45:58.437Z" }, + { url = "https://files.pythonhosted.org/packages/a9/83/6523837906d1da2b269dee787e31df3b0acb12e3d08f024965a3e7f64665/pillow-10.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bbc527b519bd3aa9d7f429d152fea69f9ad37c95f0b02aebddff592688998abe", size = 4365126, upload-time = "2024-07-01T09:46:00.713Z" }, + { url = "https://files.pythonhosted.org/packages/ba/e5/8c68ff608a4203085158cff5cc2a3c534ec384536d9438c405ed6370d080/pillow-10.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:76a911dfe51a36041f2e756b00f96ed84677cdeb75d25c767f296c1c1eda1319", size = 4533541, upload-time = "2024-07-01T09:46:03.235Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7c/01b8dbdca5bc6785573f4cee96e2358b0918b7b2c7b60d8b6f3abf87a070/pillow-10.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:59291fb29317122398786c2d44427bbd1a6d7ff54017075b22be9d21aa59bd8d", size = 4471616, upload-time = "2024-07-01T09:46:05.356Z" }, + { url = "https://files.pythonhosted.org/packages/c8/57/2899b82394a35a0fbfd352e290945440e3b3785655a03365c0ca8279f351/pillow-10.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:416d3a5d0e8cfe4f27f574362435bc9bae57f679a7158e0096ad2beb427b8696", size = 4600802, upload-time = "2024-07-01T09:46:08.145Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d7/a44f193d4c26e58ee5d2d9db3d4854b2cfb5b5e08d360a5e03fe987c0086/pillow-10.4.0-cp311-cp311-win32.whl", hash = "sha256:7086cc1d5eebb91ad24ded9f58bec6c688e9f0ed7eb3dbbf1e4800280a896496", size = 2235213, upload-time = "2024-07-01T09:46:10.211Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d0/5866318eec2b801cdb8c82abf190c8343d8a1cd8bf5a0c17444a6f268291/pillow-10.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cbed61494057c0f83b83eb3a310f0bf774b09513307c434d4366ed64f4128a91", size = 2554498, upload-time = "2024-07-01T09:46:12.685Z" }, + { url = "https://files.pythonhosted.org/packages/d4/c8/310ac16ac2b97e902d9eb438688de0d961660a87703ad1561fd3dfbd2aa0/pillow-10.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:f5f0c3e969c8f12dd2bb7e0b15d5c468b51e5017e01e2e867335c81903046a22", size = 2243219, upload-time = "2024-07-01T09:46:14.83Z" }, + { url = "https://files.pythonhosted.org/packages/05/cb/0353013dc30c02a8be34eb91d25e4e4cf594b59e5a55ea1128fde1e5f8ea/pillow-10.4.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94", size = 3509350, upload-time = "2024-07-01T09:46:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/e7/cf/5c558a0f247e0bf9cec92bff9b46ae6474dd736f6d906315e60e4075f737/pillow-10.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866b6942a92f56300012f5fbac71f2d610312ee65e22f1aa2609e491284e5597", size = 3374980, upload-time = "2024-07-01T09:46:19.169Z" }, + { url = "https://files.pythonhosted.org/packages/84/48/6e394b86369a4eb68b8a1382c78dc092245af517385c086c5094e3b34428/pillow-10.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29dbdc4207642ea6aad70fbde1a9338753d33fb23ed6956e706936706f52dd80", size = 4343799, upload-time = "2024-07-01T09:46:21.883Z" }, + { url = "https://files.pythonhosted.org/packages/3b/f3/a8c6c11fa84b59b9df0cd5694492da8c039a24cd159f0f6918690105c3be/pillow-10.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf2342ac639c4cf38799a44950bbc2dfcb685f052b9e262f446482afaf4bffca", size = 4459973, upload-time = "2024-07-01T09:46:24.321Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1b/c14b4197b80150fb64453585247e6fb2e1d93761fa0fa9cf63b102fde822/pillow-10.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f5b92f4d70791b4a67157321c4e8225d60b119c5cc9aee8ecf153aace4aad4ef", size = 4370054, upload-time = "2024-07-01T09:46:26.825Z" }, + { url = "https://files.pythonhosted.org/packages/55/77/40daddf677897a923d5d33329acd52a2144d54a9644f2a5422c028c6bf2d/pillow-10.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:86dcb5a1eb778d8b25659d5e4341269e8590ad6b4e8b44d9f4b07f8d136c414a", size = 4539484, upload-time = "2024-07-01T09:46:29.355Z" }, + { url = "https://files.pythonhosted.org/packages/40/54/90de3e4256b1207300fb2b1d7168dd912a2fb4b2401e439ba23c2b2cabde/pillow-10.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:780c072c2e11c9b2c7ca37f9a2ee8ba66f44367ac3e5c7832afcfe5104fd6d1b", size = 4477375, upload-time = "2024-07-01T09:46:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/13/24/1bfba52f44193860918ff7c93d03d95e3f8748ca1de3ceaf11157a14cf16/pillow-10.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37fb69d905be665f68f28a8bba3c6d3223c8efe1edf14cc4cfa06c241f8c81d9", size = 4608773, upload-time = "2024-07-01T09:46:33.73Z" }, + { url = "https://files.pythonhosted.org/packages/55/04/5e6de6e6120451ec0c24516c41dbaf80cce1b6451f96561235ef2429da2e/pillow-10.4.0-cp312-cp312-win32.whl", hash = "sha256:7dfecdbad5c301d7b5bde160150b4db4c659cee2b69589705b6f8a0c509d9f42", size = 2235690, upload-time = "2024-07-01T09:46:36.587Z" }, + { url = "https://files.pythonhosted.org/packages/74/0a/d4ce3c44bca8635bd29a2eab5aa181b654a734a29b263ca8efe013beea98/pillow-10.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d846aea995ad352d4bdcc847535bd56e0fd88d36829d2c90be880ef1ee4668a", size = 2554951, upload-time = "2024-07-01T09:46:38.777Z" }, + { url = "https://files.pythonhosted.org/packages/b5/ca/184349ee40f2e92439be9b3502ae6cfc43ac4b50bc4fc6b3de7957563894/pillow-10.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:e553cad5179a66ba15bb18b353a19020e73a7921296a7979c4a2b7f6a5cd57f9", size = 2243427, upload-time = "2024-07-01T09:46:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/c3/00/706cebe7c2c12a6318aabe5d354836f54adff7156fd9e1bd6c89f4ba0e98/pillow-10.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8bc1a764ed8c957a2e9cacf97c8b2b053b70307cf2996aafd70e91a082e70df3", size = 3525685, upload-time = "2024-07-01T09:46:45.194Z" }, + { url = "https://files.pythonhosted.org/packages/cf/76/f658cbfa49405e5ecbfb9ba42d07074ad9792031267e782d409fd8fe7c69/pillow-10.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6209bb41dc692ddfee4942517c19ee81b86c864b626dbfca272ec0f7cff5d9fb", size = 3374883, upload-time = "2024-07-01T09:46:47.331Z" }, + { url = "https://files.pythonhosted.org/packages/46/2b/99c28c4379a85e65378211971c0b430d9c7234b1ec4d59b2668f6299e011/pillow-10.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bee197b30783295d2eb680b311af15a20a8b24024a19c3a26431ff83eb8d1f70", size = 4339837, upload-time = "2024-07-01T09:46:49.647Z" }, + { url = "https://files.pythonhosted.org/packages/f1/74/b1ec314f624c0c43711fdf0d8076f82d9d802afd58f1d62c2a86878e8615/pillow-10.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ef61f5dd14c300786318482456481463b9d6b91ebe5ef12f405afbba77ed0be", size = 4455562, upload-time = "2024-07-01T09:46:51.811Z" }, + { url = "https://files.pythonhosted.org/packages/4a/2a/4b04157cb7b9c74372fa867096a1607e6fedad93a44deeff553ccd307868/pillow-10.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:297e388da6e248c98bc4a02e018966af0c5f92dfacf5a5ca22fa01cb3179bca0", size = 4366761, upload-time = "2024-07-01T09:46:53.961Z" }, + { url = "https://files.pythonhosted.org/packages/ac/7b/8f1d815c1a6a268fe90481232c98dd0e5fa8c75e341a75f060037bd5ceae/pillow-10.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e4db64794ccdf6cb83a59d73405f63adbe2a1887012e308828596100a0b2f6cc", size = 4536767, upload-time = "2024-07-01T09:46:56.664Z" }, + { url = "https://files.pythonhosted.org/packages/e5/77/05fa64d1f45d12c22c314e7b97398ffb28ef2813a485465017b7978b3ce7/pillow-10.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd2880a07482090a3bcb01f4265f1936a903d70bc740bfcb1fd4e8a2ffe5cf5a", size = 4477989, upload-time = "2024-07-01T09:46:58.977Z" }, + { url = "https://files.pythonhosted.org/packages/12/63/b0397cfc2caae05c3fb2f4ed1b4fc4fc878f0243510a7a6034ca59726494/pillow-10.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b35b21b819ac1dbd1233317adeecd63495f6babf21b7b2512d244ff6c6ce309", size = 4610255, upload-time = "2024-07-01T09:47:01.189Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f9/cfaa5082ca9bc4a6de66ffe1c12c2d90bf09c309a5f52b27759a596900e7/pillow-10.4.0-cp313-cp313-win32.whl", hash = "sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060", size = 2235603, upload-time = "2024-07-01T09:47:03.918Z" }, + { url = "https://files.pythonhosted.org/packages/01/6a/30ff0eef6e0c0e71e55ded56a38d4859bf9d3634a94a88743897b5f96936/pillow-10.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea", size = 2554972, upload-time = "2024-07-01T09:47:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/48/2c/2e0a52890f269435eee38b21c8218e102c621fe8d8df8b9dd06fabf879ba/pillow-10.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d", size = 2243375, upload-time = "2024-07-01T09:47:09.065Z" }, ] [[package]] @@ -1840,38 +1488,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] -[[package]] -name = "prance" -version = "23.6.21.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "chardet" }, - { name = "packaging" }, - { name = "requests" }, - { name = "ruamel-yaml" }, - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/73/f0/bcb5ffc8b7ab8e3d02dbef3bd945cf8fd6e12c146774f900659406b9fce1/prance-23.6.21.0.tar.gz", hash = "sha256:d8c15f8ac34019751cc4945f866d8d964d7888016d10de3592e339567177cabe", size = 2798776, upload-time = "2023-06-21T20:01:57.142Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/db/4fb4901ee61274d0ab97746461fc5f2637e5d73aa73f34ee28e941a699a1/prance-23.6.21.0-py3-none-any.whl", hash = "sha256:6a4276fa07ed9f22feda4331097d7503c4adc3097e46ffae97425f2c1026bd9f", size = 36279, upload-time = "2023-06-21T20:01:54.936Z" }, -] - -[[package]] -name = "pre-commit" -version = "3.7.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cfgv" }, - { name = "identify" }, - { name = "nodeenv" }, - { name = "pyyaml" }, - { name = "virtualenv" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/aa/46/cc214ef6514270328910083d0119d0a80a6d2c4ec8c6608c0219db0b74cf/pre_commit-3.7.1.tar.gz", hash = "sha256:8ca3ad567bc78a4972a3f1a477e94a79d4597e8140a6e0b651c5e33899c3654a", size = 177317, upload-time = "2024-05-11T01:25:19.473Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/0f/d6d0b4e2f5b2933a557087fc0560371aa545a18232d4d3427eb3bb3af12e/pre_commit-3.7.1-py2.py3-none-any.whl", hash = "sha256:fae36fd1d7ad7d6a5a1c0b0d5adb2ed1a3bda5a21bf6c3e5372073d7a11cd4c5", size = 204268, upload-time = "2024-05-11T01:25:16.845Z" }, -] - [[package]] name = "prompt-toolkit" version = "3.0.52" @@ -2089,15 +1705,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dd/c3/d0047678146c294469c33bae167c8ace337deafb736b0bf97b9bc481aa65/pymupdf-1.26.7-cp310-abi3-win_amd64.whl", hash = "sha256:425b1befe40d41b72eb0fe211711c7ae334db5eb60307e9dd09066ed060cceba", size = 18405952, upload-time = "2025-12-11T21:48:02.947Z" }, ] -[[package]] -name = "pypdf" -version = "6.4.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bd/c2/b59b02ff7f2dc006799d2c5dc3a8877686890abdd915176ef799070edf17/pypdf-6.4.2.tar.gz", hash = "sha256:c466ff1272ffb4712c2348d2bbc3019bc93f1c62ccfaf50808e3b9f13c3dc527", size = 5275502, upload-time = "2025-12-14T14:30:58.58Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/99/3147435e15ccd97c0451efc3d13495dc22602e9887f81e64f1b135bae821/pypdf-6.4.2-py3-none-any.whl", hash = "sha256:014dcff867fd99fc0b6fc90ed1f7e1347ef2317ae038a489c2caa64106d268f4", size = 328212, upload-time = "2025-12-14T14:30:56.701Z" }, -] - [[package]] name = "pytest" version = "8.2.0" @@ -2146,24 +1753,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, ] -[[package]] -name = "python-iso639" -version = "2025.11.16" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/3b/3e07aadeeb7bbb2574d6aa6ccacbc58b17bd2b1fb6c7196bf96ab0e45129/python_iso639-2025.11.16.tar.gz", hash = "sha256:aabe941267898384415a509f5236d7cfc191198c84c5c6f73dac73d9783f5169", size = 174186, upload-time = "2025-11-16T21:53:37.031Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/2d/563849c31e58eb2e273fa0c391a7d9987db32f4d9152fe6ecdac0a8ffe93/python_iso639-2025.11.16-py3-none-any.whl", hash = "sha256:65f6ac6c6d8e8207f6175f8bf7fff7db486c6dc5c1d8866c2b77d2a923370896", size = 167818, upload-time = "2025-11-16T21:53:35.36Z" }, -] - -[[package]] -name = "python-magic" -version = "0.4.27" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/da/db/0b3e28ac047452d079d375ec6798bf76a036a08182dbb39ed38116a49130/python-magic-0.4.27.tar.gz", hash = "sha256:c1ba14b08e4a5f5c31a302b7721239695b2f0f058d125bd5ce1ee36b9d9d3c3b", size = 14677, upload-time = "2022-06-07T20:16:59.508Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/73/9f872cb81fc5c3bb48f7227872c28975f998f3e7c2b1c16e95e6432bbb90/python_magic-0.4.27-py2.py3-none-any.whl", hash = "sha256:c212960ad306f700aa0d01e5d7a325d20548ff97eb9920dcd29513174f0294d3", size = 13840, upload-time = "2022-06-07T20:16:57.763Z" }, -] - [[package]] name = "python-multipart" version = "0.0.21" @@ -2314,85 +1903,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/1b/5dbe84eefc86f48473947e2f41711aded97eecef1231f4558f1f02713c12/pyzmq-27.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c9f7f6e13dff2e44a6afeaf2cf54cee5929ad64afaf4d40b50f93c58fc687355", size = 544862, upload-time = "2025-09-08T23:09:56.509Z" }, ] -[[package]] -name = "rapidfuzz" -version = "3.14.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d3/28/9d808fe62375b9aab5ba92fa9b29371297b067c2790b2d7cda648b1e2f8d/rapidfuzz-3.14.3.tar.gz", hash = "sha256:2491937177868bc4b1e469087601d53f925e8d270ccc21e07404b4b5814b7b5f", size = 57863900, upload-time = "2025-11-01T11:54:52.321Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/25/5b0a33ad3332ee1213068c66f7c14e9e221be90bab434f0cb4defa9d6660/rapidfuzz-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dea2d113e260a5da0c4003e0a5e9fdf24a9dc2bb9eaa43abd030a1e46ce7837d", size = 1953885, upload-time = "2025-11-01T11:52:47.75Z" }, - { url = "https://files.pythonhosted.org/packages/2d/ab/f1181f500c32c8fcf7c966f5920c7e56b9b1d03193386d19c956505c312d/rapidfuzz-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e6c31a4aa68cfa75d7eede8b0ed24b9e458447db604c2db53f358be9843d81d3", size = 1390200, upload-time = "2025-11-01T11:52:49.491Z" }, - { url = "https://files.pythonhosted.org/packages/14/2a/0f2de974ececad873865c6bb3ea3ad07c976ac293d5025b2d73325aac1d4/rapidfuzz-3.14.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02821366d928e68ddcb567fed8723dad7ea3a979fada6283e6914d5858674850", size = 1389319, upload-time = "2025-11-01T11:52:51.224Z" }, - { url = "https://files.pythonhosted.org/packages/ed/69/309d8f3a0bb3031fd9b667174cc4af56000645298af7c2931be5c3d14bb4/rapidfuzz-3.14.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cfe8df315ab4e6db4e1be72c5170f8e66021acde22cd2f9d04d2058a9fd8162e", size = 3178495, upload-time = "2025-11-01T11:52:53.005Z" }, - { url = "https://files.pythonhosted.org/packages/10/b7/f9c44a99269ea5bf6fd6a40b84e858414b6e241288b9f2b74af470d222b1/rapidfuzz-3.14.3-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:769f31c60cd79420188fcdb3c823227fc4a6deb35cafec9d14045c7f6743acae", size = 1228443, upload-time = "2025-11-01T11:52:54.991Z" }, - { url = "https://files.pythonhosted.org/packages/f2/0a/3b3137abac7f19c9220e14cd7ce993e35071a7655e7ef697785a3edfea1a/rapidfuzz-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:54fa03062124e73086dae66a3451c553c1e20a39c077fd704dc7154092c34c63", size = 2411998, upload-time = "2025-11-01T11:52:56.629Z" }, - { url = "https://files.pythonhosted.org/packages/f3/b6/983805a844d44670eaae63831024cdc97ada4e9c62abc6b20703e81e7f9b/rapidfuzz-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:834d1e818005ed0d4ae38f6b87b86fad9b0a74085467ece0727d20e15077c094", size = 2530120, upload-time = "2025-11-01T11:52:58.298Z" }, - { url = "https://files.pythonhosted.org/packages/b4/cc/2c97beb2b1be2d7595d805682472f1b1b844111027d5ad89b65e16bdbaaa/rapidfuzz-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:948b00e8476a91f510dd1ec07272efc7d78c275d83b630455559671d4e33b678", size = 4283129, upload-time = "2025-11-01T11:53:00.188Z" }, - { url = "https://files.pythonhosted.org/packages/4d/03/2f0e5e94941045aefe7eafab72320e61285c07b752df9884ce88d6b8b835/rapidfuzz-3.14.3-cp311-cp311-win32.whl", hash = "sha256:43d0305c36f504232f18ea04e55f2059bb89f169d3119c4ea96a0e15b59e2a91", size = 1724224, upload-time = "2025-11-01T11:53:02.149Z" }, - { url = "https://files.pythonhosted.org/packages/cf/99/5fa23e204435803875daefda73fd61baeabc3c36b8fc0e34c1705aab8c7b/rapidfuzz-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:ef6bf930b947bd0735c550683939a032090f1d688dfd8861d6b45307b96fd5c5", size = 1544259, upload-time = "2025-11-01T11:53:03.66Z" }, - { url = "https://files.pythonhosted.org/packages/48/35/d657b85fcc615a42661b98ac90ce8e95bd32af474603a105643963749886/rapidfuzz-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:f3eb0ff3b75d6fdccd40b55e7414bb859a1cda77c52762c9c82b85569f5088e7", size = 814734, upload-time = "2025-11-01T11:53:05.008Z" }, - { url = "https://files.pythonhosted.org/packages/fa/8e/3c215e860b458cfbedb3ed73bc72e98eb7e0ed72f6b48099604a7a3260c2/rapidfuzz-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:685c93ea961d135893b5984a5a9851637d23767feabe414ec974f43babbd8226", size = 1945306, upload-time = "2025-11-01T11:53:06.452Z" }, - { url = "https://files.pythonhosted.org/packages/36/d9/31b33512015c899f4a6e6af64df8dfe8acddf4c8b40a4b3e0e6e1bcd00e5/rapidfuzz-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fa7c8f26f009f8c673fbfb443792f0cf8cf50c4e18121ff1e285b5e08a94fbdb", size = 1390788, upload-time = "2025-11-01T11:53:08.721Z" }, - { url = "https://files.pythonhosted.org/packages/a9/67/2ee6f8de6e2081ccd560a571d9c9063184fe467f484a17fa90311a7f4a2e/rapidfuzz-3.14.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57f878330c8d361b2ce76cebb8e3e1dc827293b6abf404e67d53260d27b5d941", size = 1374580, upload-time = "2025-11-01T11:53:10.164Z" }, - { url = "https://files.pythonhosted.org/packages/30/83/80d22997acd928eda7deadc19ccd15883904622396d6571e935993e0453a/rapidfuzz-3.14.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c5f545f454871e6af05753a0172849c82feaf0f521c5ca62ba09e1b382d6382", size = 3154947, upload-time = "2025-11-01T11:53:12.093Z" }, - { url = "https://files.pythonhosted.org/packages/5b/cf/9f49831085a16384695f9fb096b99662f589e30b89b4a589a1ebc1a19d34/rapidfuzz-3.14.3-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:07aa0b5d8863e3151e05026a28e0d924accf0a7a3b605da978f0359bb804df43", size = 1223872, upload-time = "2025-11-01T11:53:13.664Z" }, - { url = "https://files.pythonhosted.org/packages/c8/0f/41ee8034e744b871c2e071ef0d360686f5ccfe5659f4fd96c3ec406b3c8b/rapidfuzz-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73b07566bc7e010e7b5bd490fb04bb312e820970180df6b5655e9e6224c137db", size = 2392512, upload-time = "2025-11-01T11:53:15.109Z" }, - { url = "https://files.pythonhosted.org/packages/da/86/280038b6b0c2ccec54fb957c732ad6b41cc1fd03b288d76545b9cf98343f/rapidfuzz-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6de00eb84c71476af7d3110cf25d8fe7c792d7f5fa86764ef0b4ca97e78ca3ed", size = 2521398, upload-time = "2025-11-01T11:53:17.146Z" }, - { url = "https://files.pythonhosted.org/packages/fa/7b/05c26f939607dca0006505e3216248ae2de631e39ef94dd63dbbf0860021/rapidfuzz-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d7843a1abf0091773a530636fdd2a49a41bcae22f9910b86b4f903e76ddc82dc", size = 4259416, upload-time = "2025-11-01T11:53:19.34Z" }, - { url = "https://files.pythonhosted.org/packages/40/eb/9e3af4103d91788f81111af1b54a28de347cdbed8eaa6c91d5e98a889aab/rapidfuzz-3.14.3-cp312-cp312-win32.whl", hash = "sha256:dea97ac3ca18cd3ba8f3d04b5c1fe4aa60e58e8d9b7793d3bd595fdb04128d7a", size = 1709527, upload-time = "2025-11-01T11:53:20.949Z" }, - { url = "https://files.pythonhosted.org/packages/b8/63/d06ecce90e2cf1747e29aeab9f823d21e5877a4c51b79720b2d3be7848f8/rapidfuzz-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:b5100fd6bcee4d27f28f4e0a1c6b5127bc8ba7c2a9959cad9eab0bf4a7ab3329", size = 1538989, upload-time = "2025-11-01T11:53:22.428Z" }, - { url = "https://files.pythonhosted.org/packages/fc/6d/beee32dcda64af8128aab3ace2ccb33d797ed58c434c6419eea015fec779/rapidfuzz-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:4e49c9e992bc5fc873bd0fff7ef16a4405130ec42f2ce3d2b735ba5d3d4eb70f", size = 811161, upload-time = "2025-11-01T11:53:23.811Z" }, - { url = "https://files.pythonhosted.org/packages/e4/4f/0d94d09646853bd26978cb3a7541b6233c5760687777fa97da8de0d9a6ac/rapidfuzz-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dbcb726064b12f356bf10fffdb6db4b6dce5390b23627c08652b3f6e49aa56ae", size = 1939646, upload-time = "2025-11-01T11:53:25.292Z" }, - { url = "https://files.pythonhosted.org/packages/b6/eb/f96aefc00f3bbdbab9c0657363ea8437a207d7545ac1c3789673e05d80bd/rapidfuzz-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1704fc70d214294e554a2421b473779bcdeef715881c5e927dc0f11e1692a0ff", size = 1385512, upload-time = "2025-11-01T11:53:27.594Z" }, - { url = "https://files.pythonhosted.org/packages/26/34/71c4f7749c12ee223dba90017a5947e8f03731a7cc9f489b662a8e9e643d/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc65e72790ddfd310c2c8912b45106e3800fefe160b0c2ef4d6b6fec4e826457", size = 1373571, upload-time = "2025-11-01T11:53:29.096Z" }, - { url = "https://files.pythonhosted.org/packages/32/00/ec8597a64f2be301ce1ee3290d067f49f6a7afb226b67d5f15b56d772ba5/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e38c1305cffae8472572a0584d4ffc2f130865586a81038ca3965301f7c97c", size = 3156759, upload-time = "2025-11-01T11:53:30.777Z" }, - { url = "https://files.pythonhosted.org/packages/61/d5/b41eeb4930501cc899d5a9a7b5c9a33d85a670200d7e81658626dcc0ecc0/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:e195a77d06c03c98b3fc06b8a28576ba824392ce40de8c708f96ce04849a052e", size = 1222067, upload-time = "2025-11-01T11:53:32.334Z" }, - { url = "https://files.pythonhosted.org/packages/2a/7d/6d9abb4ffd1027c6ed837b425834f3bed8344472eb3a503ab55b3407c721/rapidfuzz-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b7ef2f4b8583a744338a18f12c69693c194fb6777c0e9ada98cd4d9e8f09d10", size = 2394775, upload-time = "2025-11-01T11:53:34.24Z" }, - { url = "https://files.pythonhosted.org/packages/15/ce/4f3ab4c401c5a55364da1ffff8cc879fc97b4e5f4fa96033827da491a973/rapidfuzz-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a2135b138bcdcb4c3742d417f215ac2d8c2b87bde15b0feede231ae95f09ec41", size = 2526123, upload-time = "2025-11-01T11:53:35.779Z" }, - { url = "https://files.pythonhosted.org/packages/c1/4b/54f804975376a328f57293bd817c12c9036171d15cf7292032e3f5820b2d/rapidfuzz-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:33a325ed0e8e1aa20c3e75f8ab057a7b248fdea7843c2a19ade0008906c14af0", size = 4262874, upload-time = "2025-11-01T11:53:37.866Z" }, - { url = "https://files.pythonhosted.org/packages/e9/b6/958db27d8a29a50ee6edd45d33debd3ce732e7209183a72f57544cd5fe22/rapidfuzz-3.14.3-cp313-cp313-win32.whl", hash = "sha256:8383b6d0d92f6cd008f3c9216535be215a064b2cc890398a678b56e6d280cb63", size = 1707972, upload-time = "2025-11-01T11:53:39.442Z" }, - { url = "https://files.pythonhosted.org/packages/07/75/fde1f334b0cec15b5946d9f84d73250fbfcc73c236b4bc1b25129d90876b/rapidfuzz-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:e6b5e3036976f0fde888687d91be86d81f9ac5f7b02e218913c38285b756be6c", size = 1537011, upload-time = "2025-11-01T11:53:40.92Z" }, - { url = "https://files.pythonhosted.org/packages/2e/d7/d83fe001ce599dc7ead57ba1debf923dc961b6bdce522b741e6b8c82f55c/rapidfuzz-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:7ba009977601d8b0828bfac9a110b195b3e4e79b350dcfa48c11269a9f1918a0", size = 810744, upload-time = "2025-11-01T11:53:42.723Z" }, - { url = "https://files.pythonhosted.org/packages/92/13/a486369e63ff3c1a58444d16b15c5feb943edd0e6c28a1d7d67cb8946b8f/rapidfuzz-3.14.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0a28add871425c2fe94358c6300bbeb0bc2ed828ca003420ac6825408f5a424", size = 1967702, upload-time = "2025-11-01T11:53:44.554Z" }, - { url = "https://files.pythonhosted.org/packages/f1/82/efad25e260b7810f01d6b69122685e355bed78c94a12784bac4e0beb2afb/rapidfuzz-3.14.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:010e12e2411a4854b0434f920e72b717c43f8ec48d57e7affe5c42ecfa05dd0e", size = 1410702, upload-time = "2025-11-01T11:53:46.066Z" }, - { url = "https://files.pythonhosted.org/packages/ba/1a/34c977b860cde91082eae4a97ae503f43e0d84d4af301d857679b66f9869/rapidfuzz-3.14.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cfc3d57abd83c734d1714ec39c88a34dd69c85474918ebc21296f1e61eb5ca8", size = 1382337, upload-time = "2025-11-01T11:53:47.62Z" }, - { url = "https://files.pythonhosted.org/packages/88/74/f50ea0e24a5880a9159e8fd256b84d8f4634c2f6b4f98028bdd31891d907/rapidfuzz-3.14.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89acb8cbb52904f763e5ac238083b9fc193bed8d1f03c80568b20e4cef43a519", size = 3165563, upload-time = "2025-11-01T11:53:49.216Z" }, - { url = "https://files.pythonhosted.org/packages/e8/7a/e744359404d7737049c26099423fc54bcbf303de5d870d07d2fb1410f567/rapidfuzz-3.14.3-cp313-cp313t-manylinux_2_31_armv7l.whl", hash = "sha256:7d9af908c2f371bfb9c985bd134e295038e3031e666e4b2ade1e7cb7f5af2f1a", size = 1214727, upload-time = "2025-11-01T11:53:50.883Z" }, - { url = "https://files.pythonhosted.org/packages/d3/2e/87adfe14ce75768ec6c2b8acd0e05e85e84be4be5e3d283cdae360afc4fe/rapidfuzz-3.14.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1f1925619627f8798f8c3a391d81071336942e5fe8467bc3c567f982e7ce2897", size = 2403349, upload-time = "2025-11-01T11:53:52.322Z" }, - { url = "https://files.pythonhosted.org/packages/70/17/6c0b2b2bff9c8b12e12624c07aa22e922b0c72a490f180fa9183d1ef2c75/rapidfuzz-3.14.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:152555187360978119e98ce3e8263d70dd0c40c7541193fc302e9b7125cf8f58", size = 2507596, upload-time = "2025-11-01T11:53:53.835Z" }, - { url = "https://files.pythonhosted.org/packages/c3/d1/87852a7cbe4da7b962174c749a47433881a63a817d04f3e385ea9babcd9e/rapidfuzz-3.14.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:52619d25a09546b8db078981ca88939d72caa6b8701edd8b22e16482a38e799f", size = 4273595, upload-time = "2025-11-01T11:53:55.961Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ab/1d0354b7d1771a28fa7fe089bc23acec2bdd3756efa2419f463e3ed80e16/rapidfuzz-3.14.3-cp313-cp313t-win32.whl", hash = "sha256:489ce98a895c98cad284f0a47960c3e264c724cb4cfd47a1430fa091c0c25204", size = 1757773, upload-time = "2025-11-01T11:53:57.628Z" }, - { url = "https://files.pythonhosted.org/packages/0b/0c/71ef356adc29e2bdf74cd284317b34a16b80258fa0e7e242dd92cc1e6d10/rapidfuzz-3.14.3-cp313-cp313t-win_amd64.whl", hash = "sha256:656e52b054d5b5c2524169240e50cfa080b04b1c613c5f90a2465e84888d6f15", size = 1576797, upload-time = "2025-11-01T11:53:59.455Z" }, - { url = "https://files.pythonhosted.org/packages/fe/d2/0e64fc27bb08d4304aa3d11154eb5480bcf5d62d60140a7ee984dc07468a/rapidfuzz-3.14.3-cp313-cp313t-win_arm64.whl", hash = "sha256:c7e40c0a0af02ad6e57e89f62bef8604f55a04ecae90b0ceeda591bbf5923317", size = 829940, upload-time = "2025-11-01T11:54:01.1Z" }, - { url = "https://files.pythonhosted.org/packages/32/6f/1b88aaeade83abc5418788f9e6b01efefcd1a69d65ded37d89cd1662be41/rapidfuzz-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:442125473b247227d3f2de807a11da6c08ccf536572d1be943f8e262bae7e4ea", size = 1942086, upload-time = "2025-11-01T11:54:02.592Z" }, - { url = "https://files.pythonhosted.org/packages/a0/2c/b23861347436cb10f46c2bd425489ec462790faaa360a54a7ede5f78de88/rapidfuzz-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ec0c8c0c3d4f97ced46b2e191e883f8c82dbbf6d5ebc1842366d7eff13cd5a6", size = 1386993, upload-time = "2025-11-01T11:54:04.12Z" }, - { url = "https://files.pythonhosted.org/packages/83/86/5d72e2c060aa1fbdc1f7362d938f6b237dff91f5b9fc5dd7cc297e112250/rapidfuzz-3.14.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2dc37bc20272f388b8c3a4eba4febc6e77e50a8f450c472def4751e7678f55e4", size = 1379126, upload-time = "2025-11-01T11:54:05.777Z" }, - { url = "https://files.pythonhosted.org/packages/c9/bc/ef2cee3e4d8b3fc22705ff519f0d487eecc756abdc7c25d53686689d6cf2/rapidfuzz-3.14.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dee362e7e79bae940a5e2b3f6d09c6554db6a4e301cc68343886c08be99844f1", size = 3159304, upload-time = "2025-11-01T11:54:07.351Z" }, - { url = "https://files.pythonhosted.org/packages/a0/36/dc5f2f62bbc7bc90be1f75eeaf49ed9502094bb19290dfb4747317b17f12/rapidfuzz-3.14.3-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:4b39921df948388a863f0e267edf2c36302983459b021ab928d4b801cbe6a421", size = 1218207, upload-time = "2025-11-01T11:54:09.641Z" }, - { url = "https://files.pythonhosted.org/packages/df/7e/8f4be75c1bc62f47edf2bbbe2370ee482fae655ebcc4718ac3827ead3904/rapidfuzz-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:beda6aa9bc44d1d81242e7b291b446be352d3451f8217fcb068fc2933927d53b", size = 2401245, upload-time = "2025-11-01T11:54:11.543Z" }, - { url = "https://files.pythonhosted.org/packages/05/38/f7c92759e1bb188dd05b80d11c630ba59b8d7856657baf454ff56059c2ab/rapidfuzz-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6a014ba09657abfcfeed64b7d09407acb29af436d7fc075b23a298a7e4a6b41c", size = 2518308, upload-time = "2025-11-01T11:54:13.134Z" }, - { url = "https://files.pythonhosted.org/packages/c7/ac/85820f70fed5ecb5f1d9a55f1e1e2090ef62985ef41db289b5ac5ec56e28/rapidfuzz-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:32eeafa3abce138bb725550c0e228fc7eaeec7059aa8093d9cbbec2b58c2371a", size = 4265011, upload-time = "2025-11-01T11:54:15.087Z" }, - { url = "https://files.pythonhosted.org/packages/46/a9/616930721ea9835c918af7cde22bff17f9db3639b0c1a7f96684be7f5630/rapidfuzz-3.14.3-cp314-cp314-win32.whl", hash = "sha256:adb44d996fc610c7da8c5048775b21db60dd63b1548f078e95858c05c86876a3", size = 1742245, upload-time = "2025-11-01T11:54:17.19Z" }, - { url = "https://files.pythonhosted.org/packages/06/8a/f2fa5e9635b1ccafda4accf0e38246003f69982d7c81f2faa150014525a4/rapidfuzz-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:f3d15d8527e2b293e38ce6e437631af0708df29eafd7c9fc48210854c94472f9", size = 1584856, upload-time = "2025-11-01T11:54:18.764Z" }, - { url = "https://files.pythonhosted.org/packages/ef/97/09e20663917678a6d60d8e0e29796db175b1165e2079830430342d5298be/rapidfuzz-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:576e4b9012a67e0bf54fccb69a7b6c94d4e86a9540a62f1a5144977359133583", size = 833490, upload-time = "2025-11-01T11:54:20.753Z" }, - { url = "https://files.pythonhosted.org/packages/03/1b/6b6084576ba87bf21877c77218a0c97ba98cb285b0c02eaaee3acd7c4513/rapidfuzz-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:cec3c0da88562727dd5a5a364bd9efeb535400ff0bfb1443156dd139a1dd7b50", size = 1968658, upload-time = "2025-11-01T11:54:22.25Z" }, - { url = "https://files.pythonhosted.org/packages/38/c0/fb02a0db80d95704b0a6469cc394e8c38501abf7e1c0b2afe3261d1510c2/rapidfuzz-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d1fa009f8b1100e4880868137e7bf0501422898f7674f2adcd85d5a67f041296", size = 1410742, upload-time = "2025-11-01T11:54:23.863Z" }, - { url = "https://files.pythonhosted.org/packages/a4/72/3fbf12819fc6afc8ec75a45204013b40979d068971e535a7f3512b05e765/rapidfuzz-3.14.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b86daa7419b5e8b180690efd1fdbac43ff19230803282521c5b5a9c83977655", size = 1382810, upload-time = "2025-11-01T11:54:25.571Z" }, - { url = "https://files.pythonhosted.org/packages/0f/18/0f1991d59bb7eee28922a00f79d83eafa8c7bfb4e8edebf4af2a160e7196/rapidfuzz-3.14.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7bd1816db05d6c5ffb3a4df0a2b7b56fb8c81ef584d08e37058afa217da91b1", size = 3166349, upload-time = "2025-11-01T11:54:27.195Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f0/baa958b1989c8f88c78bbb329e969440cf330b5a01a982669986495bb980/rapidfuzz-3.14.3-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:33da4bbaf44e9755b0ce192597f3bde7372fe2e381ab305f41b707a95ac57aa7", size = 1214994, upload-time = "2025-11-01T11:54:28.821Z" }, - { url = "https://files.pythonhosted.org/packages/e4/a0/cd12ec71f9b2519a3954febc5740291cceabc64c87bc6433afcb36259f3b/rapidfuzz-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3fecce764cf5a991ee2195a844196da840aba72029b2612f95ac68a8b74946bf", size = 2403919, upload-time = "2025-11-01T11:54:30.393Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ce/019bd2176c1644098eced4f0595cb4b3ef52e4941ac9a5854f209d0a6e16/rapidfuzz-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ecd7453e02cf072258c3a6b8e930230d789d5d46cc849503729f9ce475d0e785", size = 2508346, upload-time = "2025-11-01T11:54:32.048Z" }, - { url = "https://files.pythonhosted.org/packages/23/f8/be16c68e2c9e6c4f23e8f4adbb7bccc9483200087ed28ff76c5312da9b14/rapidfuzz-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea188aa00e9bcae8c8411f006a5f2f06c4607a02f24eab0d8dc58566aa911f35", size = 4274105, upload-time = "2025-11-01T11:54:33.701Z" }, - { url = "https://files.pythonhosted.org/packages/a1/d1/5ab148e03f7e6ec8cd220ccf7af74d3aaa4de26dd96df58936beb7cba820/rapidfuzz-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:7ccbf68100c170e9a0581accbe9291850936711548c6688ce3bfb897b8c589ad", size = 1793465, upload-time = "2025-11-01T11:54:35.331Z" }, - { url = "https://files.pythonhosted.org/packages/cd/97/433b2d98e97abd9fff1c470a109b311669f44cdec8d0d5aa250aceaed1fb/rapidfuzz-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9ec02e62ae765a318d6de38df609c57fc6dacc65c0ed1fd489036834fd8a620c", size = 1623491, upload-time = "2025-11-01T11:54:38.085Z" }, - { url = "https://files.pythonhosted.org/packages/e2/f6/e2176eb94f94892441bce3ddc514c179facb65db245e7ce3356965595b19/rapidfuzz-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:e805e52322ae29aa945baf7168b6c898120fbc16d2b8f940b658a5e9e3999253", size = 851487, upload-time = "2025-11-01T11:54:40.176Z" }, - { url = "https://files.pythonhosted.org/packages/c9/33/b5bd6475c7c27164b5becc9b0e3eb978f1e3640fea590dd3dced6006ee83/rapidfuzz-3.14.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7cf174b52cb3ef5d49e45d0a1133b7e7d0ecf770ed01f97ae9962c5c91d97d23", size = 1888499, upload-time = "2025-11-01T11:54:42.094Z" }, - { url = "https://files.pythonhosted.org/packages/30/d2/89d65d4db4bb931beade9121bc71ad916b5fa9396e807d11b33731494e8e/rapidfuzz-3.14.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:442cba39957a008dfc5bdef21a9c3f4379e30ffb4e41b8555dbaf4887eca9300", size = 1336747, upload-time = "2025-11-01T11:54:43.957Z" }, - { url = "https://files.pythonhosted.org/packages/85/33/cd87d92b23f0b06e8914a61cea6850c6d495ca027f669fab7a379041827a/rapidfuzz-3.14.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1faa0f8f76ba75fd7b142c984947c280ef6558b5067af2ae9b8729b0a0f99ede", size = 1352187, upload-time = "2025-11-01T11:54:45.518Z" }, - { url = "https://files.pythonhosted.org/packages/22/20/9d30b4a1ab26aac22fff17d21dec7e9089ccddfe25151d0a8bb57001dc3d/rapidfuzz-3.14.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e6eefec45625c634926a9fd46c9e4f31118ac8f3156fff9494422cee45207e6", size = 3101472, upload-time = "2025-11-01T11:54:47.255Z" }, - { url = "https://files.pythonhosted.org/packages/b1/ad/fa2d3e5c29a04ead7eaa731c7cd1f30f9ec3c77b3a578fdf90280797cbcb/rapidfuzz-3.14.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56fefb4382bb12250f164250240b9dd7772e41c5c8ae976fd598a32292449cc5", size = 1511361, upload-time = "2025-11-01T11:54:49.057Z" }, -] - [[package]] name = "referencing" version = "0.36.2" @@ -2514,43 +2024,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] -[[package]] -name = "requests-oauthlib" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "oauthlib" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, -] - -[[package]] -name = "requests-toolbelt" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, -] - -[[package]] -name = "rfc3339-validator" -version = "0.1.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" }, -] - [[package]] name = "rpds-py" version = "0.30.0" @@ -2659,66 +2132,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, ] -[[package]] -name = "ruamel-yaml" -version = "0.18.17" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ruamel-yaml-clib", marker = "python_full_version < '3.15' and platform_python_implementation == 'CPython'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3a/2b/7a1f1ebcd6b3f14febdc003e658778d81e76b40df2267904ee6b13f0c5c6/ruamel_yaml-0.18.17.tar.gz", hash = "sha256:9091cd6e2d93a3a4b157ddb8fabf348c3de7f1fb1381346d985b6b247dcd8d3c", size = 149602, upload-time = "2025-12-17T20:02:55.757Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/af/fe/b6045c782f1fd1ae317d2a6ca1884857ce5c20f59befe6ab25a8603c43a7/ruamel_yaml-0.18.17-py3-none-any.whl", hash = "sha256:9c8ba9eb3e793efdf924b60d521820869d5bf0cb9c6f1b82d82de8295e290b9d", size = 121594, upload-time = "2025-12-17T20:02:07.657Z" }, -] - -[[package]] -name = "ruamel-yaml-clib" -version = "0.2.15" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ea/97/60fda20e2fb54b83a61ae14648b0817c8f5d84a3821e40bfbdae1437026a/ruamel_yaml_clib-0.2.15.tar.gz", hash = "sha256:46e4cc8c43ef6a94885f72512094e482114a8a706d3c555a34ed4b0d20200600", size = 225794, upload-time = "2025-11-16T16:12:59.761Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/80/8ce7b9af532aa94dd83360f01ce4716264db73de6bc8efd22c32341f6658/ruamel_yaml_clib-0.2.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c583229f336682b7212a43d2fa32c30e643d3076178fb9f7a6a14dde85a2d8bd", size = 147998, upload-time = "2025-11-16T16:13:13.241Z" }, - { url = "https://files.pythonhosted.org/packages/53/09/de9d3f6b6701ced5f276d082ad0f980edf08ca67114523d1b9264cd5e2e0/ruamel_yaml_clib-0.2.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:56ea19c157ed8c74b6be51b5fa1c3aff6e289a041575f0556f66e5fb848bb137", size = 132743, upload-time = "2025-11-16T16:13:14.265Z" }, - { url = "https://files.pythonhosted.org/packages/0e/f7/73a9b517571e214fe5c246698ff3ed232f1ef863c8ae1667486625ec688a/ruamel_yaml_clib-0.2.15-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5fea0932358e18293407feb921d4f4457db837b67ec1837f87074667449f9401", size = 731459, upload-time = "2025-11-16T20:22:44.338Z" }, - { url = "https://files.pythonhosted.org/packages/9b/a2/0dc0013169800f1c331a6f55b1282c1f4492a6d32660a0cf7b89e6684919/ruamel_yaml_clib-0.2.15-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71831bd61fbdb7aa0399d5c4da06bea37107ab5c79ff884cc07f2450910262", size = 749289, upload-time = "2025-11-16T16:13:15.633Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ed/3fb20a1a96b8dc645d88c4072df481fe06e0289e4d528ebbdcc044ebc8b3/ruamel_yaml_clib-0.2.15-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:617d35dc765715fa86f8c3ccdae1e4229055832c452d4ec20856136acc75053f", size = 777630, upload-time = "2025-11-16T16:13:16.898Z" }, - { url = "https://files.pythonhosted.org/packages/60/50/6842f4628bc98b7aa4733ab2378346e1441e150935ad3b9f3c3c429d9408/ruamel_yaml_clib-0.2.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b45498cc81a4724a2d42273d6cfc243c0547ad7c6b87b4f774cb7bcc131c98d", size = 744368, upload-time = "2025-11-16T16:13:18.117Z" }, - { url = "https://files.pythonhosted.org/packages/d3/b0/128ae8e19a7d794c2e36130a72b3bb650ce1dd13fb7def6cf10656437dcf/ruamel_yaml_clib-0.2.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:def5663361f6771b18646620fca12968aae730132e104688766cf8a3b1d65922", size = 745233, upload-time = "2025-11-16T20:22:45.833Z" }, - { url = "https://files.pythonhosted.org/packages/75/05/91130633602d6ba7ce3e07f8fc865b40d2a09efd4751c740df89eed5caf9/ruamel_yaml_clib-0.2.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:014181cdec565c8745b7cbc4de3bf2cc8ced05183d986e6d1200168e5bb59490", size = 770963, upload-time = "2025-11-16T16:13:19.344Z" }, - { url = "https://files.pythonhosted.org/packages/fd/4b/fd4542e7f33d7d1bc64cc9ac9ba574ce8cf145569d21f5f20133336cdc8c/ruamel_yaml_clib-0.2.15-cp311-cp311-win32.whl", hash = "sha256:d290eda8f6ada19e1771b54e5706b8f9807e6bb08e873900d5ba114ced13e02c", size = 102640, upload-time = "2025-11-16T16:13:20.498Z" }, - { url = "https://files.pythonhosted.org/packages/bb/eb/00ff6032c19c7537371e3119287999570867a0eafb0154fccc80e74bf57a/ruamel_yaml_clib-0.2.15-cp311-cp311-win_amd64.whl", hash = "sha256:bdc06ad71173b915167702f55d0f3f027fc61abd975bd308a0968c02db4a4c3e", size = 121996, upload-time = "2025-11-16T16:13:21.855Z" }, - { url = "https://files.pythonhosted.org/packages/72/4b/5fde11a0722d676e469d3d6f78c6a17591b9c7e0072ca359801c4bd17eee/ruamel_yaml_clib-0.2.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb15a2e2a90c8475df45c0949793af1ff413acfb0a716b8b94e488ea95ce7cff", size = 149088, upload-time = "2025-11-16T16:13:22.836Z" }, - { url = "https://files.pythonhosted.org/packages/85/82/4d08ac65ecf0ef3b046421985e66301a242804eb9a62c93ca3437dc94ee0/ruamel_yaml_clib-0.2.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:64da03cbe93c1e91af133f5bec37fd24d0d4ba2418eaf970d7166b0a26a148a2", size = 134553, upload-time = "2025-11-16T16:13:24.151Z" }, - { url = "https://files.pythonhosted.org/packages/b9/cb/22366d68b280e281a932403b76da7a988108287adff2bfa5ce881200107a/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f6d3655e95a80325b84c4e14c080b2470fe4f33b6846f288379ce36154993fb1", size = 737468, upload-time = "2025-11-16T20:22:47.335Z" }, - { url = "https://files.pythonhosted.org/packages/71/73/81230babf8c9e33770d43ed9056f603f6f5f9665aea4177a2c30ae48e3f3/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71845d377c7a47afc6592aacfea738cc8a7e876d586dfba814501d8c53c1ba60", size = 753349, upload-time = "2025-11-16T16:13:26.269Z" }, - { url = "https://files.pythonhosted.org/packages/61/62/150c841f24cda9e30f588ef396ed83f64cfdc13b92d2f925bb96df337ba9/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11e5499db1ccbc7f4b41f0565e4f799d863ea720e01d3e99fa0b7b5fcd7802c9", size = 788211, upload-time = "2025-11-16T16:13:27.441Z" }, - { url = "https://files.pythonhosted.org/packages/30/93/e79bd9cbecc3267499d9ead919bd61f7ddf55d793fb5ef2b1d7d92444f35/ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4b293a37dc97e2b1e8a1aec62792d1e52027087c8eea4fc7b5abd2bdafdd6642", size = 743203, upload-time = "2025-11-16T16:13:28.671Z" }, - { url = "https://files.pythonhosted.org/packages/8d/06/1eb640065c3a27ce92d76157f8efddb184bd484ed2639b712396a20d6dce/ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:512571ad41bba04eac7268fe33f7f4742210ca26a81fe0c75357fa682636c690", size = 747292, upload-time = "2025-11-16T20:22:48.584Z" }, - { url = "https://files.pythonhosted.org/packages/a5/21/ee353e882350beab65fcc47a91b6bdc512cace4358ee327af2962892ff16/ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5e9f630c73a490b758bf14d859a39f375e6999aea5ddd2e2e9da89b9953486a", size = 771624, upload-time = "2025-11-16T16:13:29.853Z" }, - { url = "https://files.pythonhosted.org/packages/57/34/cc1b94057aa867c963ecf9ea92ac59198ec2ee3a8d22a126af0b4d4be712/ruamel_yaml_clib-0.2.15-cp312-cp312-win32.whl", hash = "sha256:f4421ab780c37210a07d138e56dd4b51f8642187cdfb433eb687fe8c11de0144", size = 100342, upload-time = "2025-11-16T16:13:31.067Z" }, - { url = "https://files.pythonhosted.org/packages/b3/e5/8925a4208f131b218f9a7e459c0d6fcac8324ae35da269cb437894576366/ruamel_yaml_clib-0.2.15-cp312-cp312-win_amd64.whl", hash = "sha256:2b216904750889133d9222b7b873c199d48ecbb12912aca78970f84a5aa1a4bc", size = 119013, upload-time = "2025-11-16T16:13:32.164Z" }, - { url = "https://files.pythonhosted.org/packages/17/5e/2f970ce4c573dc30c2f95825f2691c96d55560268ddc67603dc6ea2dd08e/ruamel_yaml_clib-0.2.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dcec721fddbb62e60c2801ba08c87010bd6b700054a09998c4d09c08147b8fb", size = 147450, upload-time = "2025-11-16T16:13:33.542Z" }, - { url = "https://files.pythonhosted.org/packages/d6/03/a1baa5b94f71383913f21b96172fb3a2eb5576a4637729adbf7cd9f797f8/ruamel_yaml_clib-0.2.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:65f48245279f9bb301d1276f9679b82e4c080a1ae25e679f682ac62446fac471", size = 133139, upload-time = "2025-11-16T16:13:34.587Z" }, - { url = "https://files.pythonhosted.org/packages/dc/19/40d676802390f85784235a05788fd28940923382e3f8b943d25febbb98b7/ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46895c17ead5e22bea5e576f1db7e41cb273e8d062c04a6a49013d9f60996c25", size = 731474, upload-time = "2025-11-16T20:22:49.934Z" }, - { url = "https://files.pythonhosted.org/packages/ce/bb/6ef5abfa43b48dd55c30d53e997f8f978722f02add61efba31380d73e42e/ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3eb199178b08956e5be6288ee0b05b2fb0b5c1f309725ad25d9c6ea7e27f962a", size = 748047, upload-time = "2025-11-16T16:13:35.633Z" }, - { url = "https://files.pythonhosted.org/packages/ff/5d/e4f84c9c448613e12bd62e90b23aa127ea4c46b697f3d760acc32cb94f25/ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d1032919280ebc04a80e4fb1e93f7a738129857eaec9448310e638c8bccefcf", size = 782129, upload-time = "2025-11-16T16:13:36.781Z" }, - { url = "https://files.pythonhosted.org/packages/de/4b/e98086e88f76c00c88a6bcf15eae27a1454f661a9eb72b111e6bbb69024d/ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab0df0648d86a7ecbd9c632e8f8d6b21bb21b5fc9d9e095c796cacf32a728d2d", size = 736848, upload-time = "2025-11-16T16:13:37.952Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5c/5964fcd1fd9acc53b7a3a5d9a05ea4f95ead9495d980003a557deb9769c7/ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:331fb180858dd8534f0e61aa243b944f25e73a4dae9962bd44c46d1761126bbf", size = 741630, upload-time = "2025-11-16T20:22:51.718Z" }, - { url = "https://files.pythonhosted.org/packages/07/1e/99660f5a30fceb58494598e7d15df883a07292346ef5696f0c0ae5dee8c6/ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd4c928ddf6bce586285daa6d90680b9c291cfd045fc40aad34e445d57b1bf51", size = 766619, upload-time = "2025-11-16T16:13:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/36/2f/fa0344a9327b58b54970e56a27b32416ffbcfe4dcc0700605516708579b2/ruamel_yaml_clib-0.2.15-cp313-cp313-win32.whl", hash = "sha256:bf0846d629e160223805db9fe8cc7aec16aaa11a07310c50c8c7164efa440aec", size = 100171, upload-time = "2025-11-16T16:13:40.456Z" }, - { url = "https://files.pythonhosted.org/packages/06/c4/c124fbcef0684fcf3c9b72374c2a8c35c94464d8694c50f37eef27f5a145/ruamel_yaml_clib-0.2.15-cp313-cp313-win_amd64.whl", hash = "sha256:45702dfbea1420ba3450bb3dd9a80b33f0badd57539c6aac09f42584303e0db6", size = 118845, upload-time = "2025-11-16T16:13:41.481Z" }, - { url = "https://files.pythonhosted.org/packages/3e/bd/ab8459c8bb759c14a146990bf07f632c1cbec0910d4853feeee4be2ab8bb/ruamel_yaml_clib-0.2.15-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:753faf20b3a5906faf1fc50e4ddb8c074cb9b251e00b14c18b28492f933ac8ef", size = 147248, upload-time = "2025-11-16T16:13:42.872Z" }, - { url = "https://files.pythonhosted.org/packages/69/f2/c4cec0a30f1955510fde498aac451d2e52b24afdbcb00204d3a951b772c3/ruamel_yaml_clib-0.2.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:480894aee0b29752560a9de46c0e5f84a82602f2bc5c6cde8db9a345319acfdf", size = 133764, upload-time = "2025-11-16T16:13:43.932Z" }, - { url = "https://files.pythonhosted.org/packages/82/c7/2480d062281385a2ea4f7cc9476712446e0c548cd74090bff92b4b49e898/ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d3b58ab2454b4747442ac76fab66739c72b1e2bb9bd173d7694b9f9dbc9c000", size = 730537, upload-time = "2025-11-16T20:22:52.918Z" }, - { url = "https://files.pythonhosted.org/packages/75/08/e365ee305367559f57ba6179d836ecc3d31c7d3fdff2a40ebf6c32823a1f/ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bfd309b316228acecfa30670c3887dcedf9b7a44ea39e2101e75d2654522acd4", size = 746944, upload-time = "2025-11-16T16:13:45.338Z" }, - { url = "https://files.pythonhosted.org/packages/a1/5c/8b56b08db91e569d0a4fbfa3e492ed2026081bdd7e892f63ba1c88a2f548/ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2812ff359ec1f30129b62372e5f22a52936fac13d5d21e70373dbca5d64bb97c", size = 778249, upload-time = "2025-11-16T16:13:46.871Z" }, - { url = "https://files.pythonhosted.org/packages/6a/1d/70dbda370bd0e1a92942754c873bd28f513da6198127d1736fa98bb2a16f/ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7e74ea87307303ba91073b63e67f2c667e93f05a8c63079ee5b7a5c8d0d7b043", size = 737140, upload-time = "2025-11-16T16:13:48.349Z" }, - { url = "https://files.pythonhosted.org/packages/5b/87/822d95874216922e1120afb9d3fafa795a18fdd0c444f5c4c382f6dac761/ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:713cd68af9dfbe0bb588e144a61aad8dcc00ef92a82d2e87183ca662d242f524", size = 741070, upload-time = "2025-11-16T20:22:54.151Z" }, - { url = "https://files.pythonhosted.org/packages/b9/17/4e01a602693b572149f92c983c1f25bd608df02c3f5cf50fd1f94e124a59/ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:542d77b72786a35563f97069b9379ce762944e67055bea293480f7734b2c7e5e", size = 765882, upload-time = "2025-11-16T16:13:49.526Z" }, - { url = "https://files.pythonhosted.org/packages/9f/17/7999399081d39ebb79e807314de6b611e1d1374458924eb2a489c01fc5ad/ruamel_yaml_clib-0.2.15-cp314-cp314-win32.whl", hash = "sha256:424ead8cef3939d690c4b5c85ef5b52155a231ff8b252961b6516ed7cf05f6aa", size = 102567, upload-time = "2025-11-16T16:13:50.78Z" }, - { url = "https://files.pythonhosted.org/packages/d2/67/be582a7370fdc9e6846c5be4888a530dcadd055eef5b932e0e85c33c7d73/ruamel_yaml_clib-0.2.15-cp314-cp314-win_amd64.whl", hash = "sha256:ac9b8d5fa4bb7fd2917ab5027f60d4234345fd366fe39aa711d5dca090aa1467", size = 122847, upload-time = "2025-11-16T16:13:51.807Z" }, -] - [[package]] name = "safetensors" version = "0.7.0" @@ -2899,15 +2312,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] -[[package]] -name = "slack-sdk" -version = "3.31.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a5/e3/4a2491cbf793bb8da8a51120207df8c097faeda42bf720f7acf7c40e4ca8/slack_sdk-3.31.0.tar.gz", hash = "sha256:740d2f9c49cbfcbd46fca56b4be9d527934c225312aac18fd2c0fca0ef6bc935", size = 230928, upload-time = "2024-07-04T16:40:39.019Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/57/04/f4517d8403c49910f45ad91205de352f2eccf12ae28865a27da7d7d05bf6/slack_sdk-3.31.0-py2.py3-none-any.whl", hash = "sha256:a120cc461e8ebb7d9175f171dbe0ded37a6878d9f7b96b28e4bad1227399047b", size = 289845, upload-time = "2024-07-04T16:40:36.34Z" }, -] - [[package]] name = "sniffio" version = "1.3.1" @@ -2978,15 +2382,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, ] -[[package]] -name = "tabulate" -version = "0.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/fe/802052aecb21e3797b8f7902564ab6ea0d60ff8ca23952079064155d1ae1/tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c", size = 81090, upload-time = "2022-10-06T17:21:48.54Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252, upload-time = "2022-10-06T17:21:44.262Z" }, -] - [[package]] name = "texttable" version = "1.7.0" @@ -3207,19 +2602,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] -[[package]] -name = "typing-inspect" -version = "0.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mypy-extensions" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/dc/74/1789779d91f1961fa9438e9a8710cdae6bd138c80d7303996933d117264a/typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78", size = 13825, upload-time = "2023-05-24T20:25:47.612Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827, upload-time = "2023-05-24T20:25:45.287Z" }, -] - [[package]] name = "typing-inspection" version = "0.4.2" @@ -3241,53 +2623,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, ] -[[package]] -name = "unstructured" -version = "0.13.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "backoff" }, - { name = "beautifulsoup4" }, - { name = "chardet" }, - { name = "dataclasses-json" }, - { name = "emoji" }, - { name = "filetype" }, - { name = "langdetect" }, - { name = "lxml" }, - { name = "nltk" }, - { name = "numpy" }, - { name = "python-iso639" }, - { name = "python-magic" }, - { name = "rapidfuzz" }, - { name = "requests" }, - { name = "tabulate" }, - { name = "typing-extensions" }, - { name = "unstructured-client" }, - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a1/e7/f3ff63814226e349a434dec7ede51f0e5af14eed325b3fd1c48be6fb8ff1/unstructured-0.13.7.tar.gz", hash = "sha256:5d59161d353b7006d8c6ee6f1a39154a5a11a5aaa258aac3fe90a8d44016aa6c", size = 1714285, upload-time = "2024-05-08T18:36:49.562Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f5/48/9bdd8aff34c507750a347545bcf26c025b1335c021ac0b9af5a542a8acd5/unstructured-0.13.7-py3-none-any.whl", hash = "sha256:a3d8f3037cb3063661531c6ecc04aca6df93c293ba06e36d67ffc70857a6f208", size = 1915733, upload-time = "2024-05-08T18:36:45.4Z" }, -] - -[[package]] -name = "unstructured-client" -version = "0.42.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiofiles" }, - { name = "cryptography" }, - { name = "httpcore" }, - { name = "httpx" }, - { name = "pydantic" }, - { name = "pypdf" }, - { name = "requests-toolbelt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ff/fe/c6d334d4fb9a4a006125a1a8a3918be643c268290707d48e9cd060b71f7f/unstructured_client-0.42.6.tar.gz", hash = "sha256:ea54f2c4ca3e7a1330f9e77cbc96f88f829518beeec5e1b797b5352f4d76a73a", size = 94179, upload-time = "2025-12-17T03:49:58.38Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/12/5aa5d051b32d0c09077a8e83920e794b9bf2315739add4ab821e71fbca58/unstructured_client-0.42.6-py3-none-any.whl", hash = "sha256:c93b1d9d1b9f63a8e961729d00224b3659ef9ef3e14996ea4e53ddc95df671a9", size = 219563, upload-time = "2025-12-17T03:49:56.993Z" }, -] - [[package]] name = "urllib3" version = "2.6.2" @@ -3310,20 +2645,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" }, ] -[[package]] -name = "virtualenv" -version = "20.35.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "distlib" }, - { name = "filelock" }, - { name = "platformdirs" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" }, -] - [[package]] name = "wcwidth" version = "0.2.14" @@ -3396,87 +2717,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2f/f9/9e082990c2585c744734f85bec79b5dae5df9c974ffee58fe421652c8e91/werkzeug-3.1.4-py3-none-any.whl", hash = "sha256:2ad50fb9ed09cc3af22c54698351027ace879a0b60a3b5edf5730b2f7d876905", size = 224960, upload-time = "2025-11-29T02:15:21.13Z" }, ] -[[package]] -name = "wrapt" -version = "2.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/49/2a/6de8a50cb435b7f42c46126cf1a54b2aab81784e74c8595c8e025e8f36d3/wrapt-2.0.1.tar.gz", hash = "sha256:9c9c635e78497cacb81e84f8b11b23e0aacac7a136e73b8e5b2109a1d9fc468f", size = 82040, upload-time = "2025-11-07T00:45:33.312Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/98/60/553997acf3939079dab022e37b67b1904b5b0cc235503226898ba573b10c/wrapt-2.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e17283f533a0d24d6e5429a7d11f250a58d28b4ae5186f8f47853e3e70d2590", size = 77480, upload-time = "2025-11-07T00:43:30.573Z" }, - { url = "https://files.pythonhosted.org/packages/2d/50/e5b3d30895d77c52105c6d5cbf94d5b38e2a3dd4a53d22d246670da98f7c/wrapt-2.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:85df8d92158cb8f3965aecc27cf821461bb5f40b450b03facc5d9f0d4d6ddec6", size = 60690, upload-time = "2025-11-07T00:43:31.594Z" }, - { url = "https://files.pythonhosted.org/packages/f0/40/660b2898703e5cbbb43db10cdefcc294274458c3ca4c68637c2b99371507/wrapt-2.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1be685ac7700c966b8610ccc63c3187a72e33cab53526a27b2a285a662cd4f7", size = 61578, upload-time = "2025-11-07T00:43:32.918Z" }, - { url = "https://files.pythonhosted.org/packages/5b/36/825b44c8a10556957bc0c1d84c7b29a40e05fcf1873b6c40aa9dbe0bd972/wrapt-2.0.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:df0b6d3b95932809c5b3fecc18fda0f1e07452d05e2662a0b35548985f256e28", size = 114115, upload-time = "2025-11-07T00:43:35.605Z" }, - { url = "https://files.pythonhosted.org/packages/83/73/0a5d14bb1599677304d3c613a55457d34c344e9b60eda8a737c2ead7619e/wrapt-2.0.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da7384b0e5d4cae05c97cd6f94faaf78cc8b0f791fc63af43436d98c4ab37bb", size = 116157, upload-time = "2025-11-07T00:43:37.058Z" }, - { url = "https://files.pythonhosted.org/packages/01/22/1c158fe763dbf0a119f985d945711d288994fe5514c0646ebe0eb18b016d/wrapt-2.0.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ec65a78fbd9d6f083a15d7613b2800d5663dbb6bb96003899c834beaa68b242c", size = 112535, upload-time = "2025-11-07T00:43:34.138Z" }, - { url = "https://files.pythonhosted.org/packages/5c/28/4f16861af67d6de4eae9927799b559c20ebdd4fe432e89ea7fe6fcd9d709/wrapt-2.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7de3cc939be0e1174969f943f3b44e0d79b6f9a82198133a5b7fc6cc92882f16", size = 115404, upload-time = "2025-11-07T00:43:39.214Z" }, - { url = "https://files.pythonhosted.org/packages/a0/8b/7960122e625fad908f189b59c4aae2d50916eb4098b0fb2819c5a177414f/wrapt-2.0.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:fb1a5b72cbd751813adc02ef01ada0b0d05d3dcbc32976ce189a1279d80ad4a2", size = 111802, upload-time = "2025-11-07T00:43:40.476Z" }, - { url = "https://files.pythonhosted.org/packages/3e/73/7881eee5ac31132a713ab19a22c9e5f1f7365c8b1df50abba5d45b781312/wrapt-2.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3fa272ca34332581e00bf7773e993d4f632594eb2d1b0b162a9038df0fd971dd", size = 113837, upload-time = "2025-11-07T00:43:42.921Z" }, - { url = "https://files.pythonhosted.org/packages/45/00/9499a3d14e636d1f7089339f96c4409bbc7544d0889f12264efa25502ae8/wrapt-2.0.1-cp311-cp311-win32.whl", hash = "sha256:fc007fdf480c77301ab1afdbb6ab22a5deee8885f3b1ed7afcb7e5e84a0e27be", size = 58028, upload-time = "2025-11-07T00:43:47.369Z" }, - { url = "https://files.pythonhosted.org/packages/70/5d/8f3d7eea52f22638748f74b102e38fdf88cb57d08ddeb7827c476a20b01b/wrapt-2.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:47434236c396d04875180171ee1f3815ca1eada05e24a1ee99546320d54d1d1b", size = 60385, upload-time = "2025-11-07T00:43:44.34Z" }, - { url = "https://files.pythonhosted.org/packages/14/e2/32195e57a8209003587bbbad44d5922f13e0ced2a493bb46ca882c5b123d/wrapt-2.0.1-cp311-cp311-win_arm64.whl", hash = "sha256:837e31620e06b16030b1d126ed78e9383815cbac914693f54926d816d35d8edf", size = 58893, upload-time = "2025-11-07T00:43:46.161Z" }, - { url = "https://files.pythonhosted.org/packages/cb/73/8cb252858dc8254baa0ce58ce382858e3a1cf616acebc497cb13374c95c6/wrapt-2.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1fdbb34da15450f2b1d735a0e969c24bdb8d8924892380126e2a293d9902078c", size = 78129, upload-time = "2025-11-07T00:43:48.852Z" }, - { url = "https://files.pythonhosted.org/packages/19/42/44a0db2108526ee6e17a5ab72478061158f34b08b793df251d9fbb9a7eb4/wrapt-2.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3d32794fe940b7000f0519904e247f902f0149edbe6316c710a8562fb6738841", size = 61205, upload-time = "2025-11-07T00:43:50.402Z" }, - { url = "https://files.pythonhosted.org/packages/4d/8a/5b4b1e44b791c22046e90d9b175f9a7581a8cc7a0debbb930f81e6ae8e25/wrapt-2.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:386fb54d9cd903ee0012c09291336469eb7b244f7183d40dc3e86a16a4bace62", size = 61692, upload-time = "2025-11-07T00:43:51.678Z" }, - { url = "https://files.pythonhosted.org/packages/11/53/3e794346c39f462bcf1f58ac0487ff9bdad02f9b6d5ee2dc84c72e0243b2/wrapt-2.0.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7b219cb2182f230676308cdcacd428fa837987b89e4b7c5c9025088b8a6c9faf", size = 121492, upload-time = "2025-11-07T00:43:55.017Z" }, - { url = "https://files.pythonhosted.org/packages/c6/7e/10b7b0e8841e684c8ca76b462a9091c45d62e8f2de9c4b1390b690eadf16/wrapt-2.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:641e94e789b5f6b4822bb8d8ebbdfc10f4e4eae7756d648b717d980f657a9eb9", size = 123064, upload-time = "2025-11-07T00:43:56.323Z" }, - { url = "https://files.pythonhosted.org/packages/0e/d1/3c1e4321fc2f5ee7fd866b2d822aa89b84495f28676fd976c47327c5b6aa/wrapt-2.0.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe21b118b9f58859b5ebaa4b130dee18669df4bd111daad082b7beb8799ad16b", size = 117403, upload-time = "2025-11-07T00:43:53.258Z" }, - { url = "https://files.pythonhosted.org/packages/a4/b0/d2f0a413cf201c8c2466de08414a15420a25aa83f53e647b7255cc2fab5d/wrapt-2.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17fb85fa4abc26a5184d93b3efd2dcc14deb4b09edcdb3535a536ad34f0b4dba", size = 121500, upload-time = "2025-11-07T00:43:57.468Z" }, - { url = "https://files.pythonhosted.org/packages/bd/45/bddb11d28ca39970a41ed48a26d210505120f925918592283369219f83cc/wrapt-2.0.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:b89ef9223d665ab255ae42cc282d27d69704d94be0deffc8b9d919179a609684", size = 116299, upload-time = "2025-11-07T00:43:58.877Z" }, - { url = "https://files.pythonhosted.org/packages/81/af/34ba6dd570ef7a534e7eec0c25e2615c355602c52aba59413411c025a0cb/wrapt-2.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a453257f19c31b31ba593c30d997d6e5be39e3b5ad9148c2af5a7314061c63eb", size = 120622, upload-time = "2025-11-07T00:43:59.962Z" }, - { url = "https://files.pythonhosted.org/packages/e2/3e/693a13b4146646fb03254636f8bafd20c621955d27d65b15de07ab886187/wrapt-2.0.1-cp312-cp312-win32.whl", hash = "sha256:3e271346f01e9c8b1130a6a3b0e11908049fe5be2d365a5f402778049147e7e9", size = 58246, upload-time = "2025-11-07T00:44:03.169Z" }, - { url = "https://files.pythonhosted.org/packages/a7/36/715ec5076f925a6be95f37917b66ebbeaa1372d1862c2ccd7a751574b068/wrapt-2.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:2da620b31a90cdefa9cd0c2b661882329e2e19d1d7b9b920189956b76c564d75", size = 60492, upload-time = "2025-11-07T00:44:01.027Z" }, - { url = "https://files.pythonhosted.org/packages/ef/3e/62451cd7d80f65cc125f2b426b25fbb6c514bf6f7011a0c3904fc8c8df90/wrapt-2.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:aea9c7224c302bc8bfc892b908537f56c430802560e827b75ecbde81b604598b", size = 58987, upload-time = "2025-11-07T00:44:02.095Z" }, - { url = "https://files.pythonhosted.org/packages/ad/fe/41af4c46b5e498c90fc87981ab2972fbd9f0bccda597adb99d3d3441b94b/wrapt-2.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:47b0f8bafe90f7736151f61482c583c86b0693d80f075a58701dd1549b0010a9", size = 78132, upload-time = "2025-11-07T00:44:04.628Z" }, - { url = "https://files.pythonhosted.org/packages/1c/92/d68895a984a5ebbbfb175512b0c0aad872354a4a2484fbd5552e9f275316/wrapt-2.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cbeb0971e13b4bd81d34169ed57a6dda017328d1a22b62fda45e1d21dd06148f", size = 61211, upload-time = "2025-11-07T00:44:05.626Z" }, - { url = "https://files.pythonhosted.org/packages/e8/26/ba83dc5ae7cf5aa2b02364a3d9cf74374b86169906a1f3ade9a2d03cf21c/wrapt-2.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb7cffe572ad0a141a7886a1d2efa5bef0bf7fe021deeea76b3ab334d2c38218", size = 61689, upload-time = "2025-11-07T00:44:06.719Z" }, - { url = "https://files.pythonhosted.org/packages/cf/67/d7a7c276d874e5d26738c22444d466a3a64ed541f6ef35f740dbd865bab4/wrapt-2.0.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8d60527d1ecfc131426b10d93ab5d53e08a09c5fa0175f6b21b3252080c70a9", size = 121502, upload-time = "2025-11-07T00:44:09.557Z" }, - { url = "https://files.pythonhosted.org/packages/0f/6b/806dbf6dd9579556aab22fc92908a876636e250f063f71548a8660382184/wrapt-2.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c654eafb01afac55246053d67a4b9a984a3567c3808bb7df2f8de1c1caba2e1c", size = 123110, upload-time = "2025-11-07T00:44:10.64Z" }, - { url = "https://files.pythonhosted.org/packages/e5/08/cdbb965fbe4c02c5233d185d070cabed2ecc1f1e47662854f95d77613f57/wrapt-2.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98d873ed6c8b4ee2418f7afce666751854d6d03e3c0ec2a399bb039cd2ae89db", size = 117434, upload-time = "2025-11-07T00:44:08.138Z" }, - { url = "https://files.pythonhosted.org/packages/2d/d1/6aae2ce39db4cb5216302fa2e9577ad74424dfbe315bd6669725569e048c/wrapt-2.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9e850f5b7fc67af856ff054c71690d54fa940c3ef74209ad9f935b4f66a0233", size = 121533, upload-time = "2025-11-07T00:44:12.142Z" }, - { url = "https://files.pythonhosted.org/packages/79/35/565abf57559fbe0a9155c29879ff43ce8bd28d2ca61033a3a3dd67b70794/wrapt-2.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e505629359cb5f751e16e30cf3f91a1d3ddb4552480c205947da415d597f7ac2", size = 116324, upload-time = "2025-11-07T00:44:13.28Z" }, - { url = "https://files.pythonhosted.org/packages/e1/e0/53ff5e76587822ee33e560ad55876d858e384158272cd9947abdd4ad42ca/wrapt-2.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2879af909312d0baf35f08edeea918ee3af7ab57c37fe47cb6a373c9f2749c7b", size = 120627, upload-time = "2025-11-07T00:44:14.431Z" }, - { url = "https://files.pythonhosted.org/packages/7c/7b/38df30fd629fbd7612c407643c63e80e1c60bcc982e30ceeae163a9800e7/wrapt-2.0.1-cp313-cp313-win32.whl", hash = "sha256:d67956c676be5a24102c7407a71f4126d30de2a569a1c7871c9f3cabc94225d7", size = 58252, upload-time = "2025-11-07T00:44:17.814Z" }, - { url = "https://files.pythonhosted.org/packages/85/64/d3954e836ea67c4d3ad5285e5c8fd9d362fd0a189a2db622df457b0f4f6a/wrapt-2.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:9ca66b38dd642bf90c59b6738af8070747b610115a39af2498535f62b5cdc1c3", size = 60500, upload-time = "2025-11-07T00:44:15.561Z" }, - { url = "https://files.pythonhosted.org/packages/89/4e/3c8b99ac93527cfab7f116089db120fef16aac96e5f6cdb724ddf286086d/wrapt-2.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:5a4939eae35db6b6cec8e7aa0e833dcca0acad8231672c26c2a9ab7a0f8ac9c8", size = 58993, upload-time = "2025-11-07T00:44:16.65Z" }, - { url = "https://files.pythonhosted.org/packages/f9/f4/eff2b7d711cae20d220780b9300faa05558660afb93f2ff5db61fe725b9a/wrapt-2.0.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a52f93d95c8d38fed0669da2ebdb0b0376e895d84596a976c15a9eb45e3eccb3", size = 82028, upload-time = "2025-11-07T00:44:18.944Z" }, - { url = "https://files.pythonhosted.org/packages/0c/67/cb945563f66fd0f61a999339460d950f4735c69f18f0a87ca586319b1778/wrapt-2.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4e54bbf554ee29fcceee24fa41c4d091398b911da6e7f5d7bffda963c9aed2e1", size = 62949, upload-time = "2025-11-07T00:44:20.074Z" }, - { url = "https://files.pythonhosted.org/packages/ec/ca/f63e177f0bbe1e5cf5e8d9b74a286537cd709724384ff20860f8f6065904/wrapt-2.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:908f8c6c71557f4deaa280f55d0728c3bca0960e8c3dd5ceeeafb3c19942719d", size = 63681, upload-time = "2025-11-07T00:44:21.345Z" }, - { url = "https://files.pythonhosted.org/packages/39/a1/1b88fcd21fd835dca48b556daef750952e917a2794fa20c025489e2e1f0f/wrapt-2.0.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e2f84e9af2060e3904a32cea9bb6db23ce3f91cfd90c6b426757cf7cc01c45c7", size = 152696, upload-time = "2025-11-07T00:44:24.318Z" }, - { url = "https://files.pythonhosted.org/packages/62/1c/d9185500c1960d9f5f77b9c0b890b7fc62282b53af7ad1b6bd779157f714/wrapt-2.0.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3612dc06b436968dfb9142c62e5dfa9eb5924f91120b3c8ff501ad878f90eb3", size = 158859, upload-time = "2025-11-07T00:44:25.494Z" }, - { url = "https://files.pythonhosted.org/packages/91/60/5d796ed0f481ec003220c7878a1d6894652efe089853a208ea0838c13086/wrapt-2.0.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d2d947d266d99a1477cd005b23cbd09465276e302515e122df56bb9511aca1b", size = 146068, upload-time = "2025-11-07T00:44:22.81Z" }, - { url = "https://files.pythonhosted.org/packages/04/f8/75282dd72f102ddbfba137e1e15ecba47b40acff32c08ae97edbf53f469e/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7d539241e87b650cbc4c3ac9f32c8d1ac8a54e510f6dca3f6ab60dcfd48c9b10", size = 155724, upload-time = "2025-11-07T00:44:26.634Z" }, - { url = "https://files.pythonhosted.org/packages/5a/27/fe39c51d1b344caebb4a6a9372157bdb8d25b194b3561b52c8ffc40ac7d1/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:4811e15d88ee62dbf5c77f2c3ff3932b1e3ac92323ba3912f51fc4016ce81ecf", size = 144413, upload-time = "2025-11-07T00:44:27.939Z" }, - { url = "https://files.pythonhosted.org/packages/83/2b/9f6b643fe39d4505c7bf926d7c2595b7cb4b607c8c6b500e56c6b36ac238/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c1c91405fcf1d501fa5d55df21e58ea49e6b879ae829f1039faaf7e5e509b41e", size = 150325, upload-time = "2025-11-07T00:44:29.29Z" }, - { url = "https://files.pythonhosted.org/packages/bb/b6/20ffcf2558596a7f58a2e69c89597128781f0b88e124bf5a4cadc05b8139/wrapt-2.0.1-cp313-cp313t-win32.whl", hash = "sha256:e76e3f91f864e89db8b8d2a8311d57df93f01ad6bb1e9b9976d1f2e83e18315c", size = 59943, upload-time = "2025-11-07T00:44:33.211Z" }, - { url = "https://files.pythonhosted.org/packages/87/6a/0e56111cbb3320151eed5d3821ee1373be13e05b376ea0870711f18810c3/wrapt-2.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:83ce30937f0ba0d28818807b303a412440c4b63e39d3d8fc036a94764b728c92", size = 63240, upload-time = "2025-11-07T00:44:30.935Z" }, - { url = "https://files.pythonhosted.org/packages/1d/54/5ab4c53ea1f7f7e5c3e7c1095db92932cc32fd62359d285486d00c2884c3/wrapt-2.0.1-cp313-cp313t-win_arm64.whl", hash = "sha256:4b55cacc57e1dc2d0991dbe74c6419ffd415fb66474a02335cb10efd1aa3f84f", size = 60416, upload-time = "2025-11-07T00:44:32.002Z" }, - { url = "https://files.pythonhosted.org/packages/73/81/d08d83c102709258e7730d3cd25befd114c60e43ef3891d7e6877971c514/wrapt-2.0.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:5e53b428f65ece6d9dad23cb87e64506392b720a0b45076c05354d27a13351a1", size = 78290, upload-time = "2025-11-07T00:44:34.691Z" }, - { url = "https://files.pythonhosted.org/packages/f6/14/393afba2abb65677f313aa680ff0981e829626fed39b6a7e3ec807487790/wrapt-2.0.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ad3ee9d0f254851c71780966eb417ef8e72117155cff04821ab9b60549694a55", size = 61255, upload-time = "2025-11-07T00:44:35.762Z" }, - { url = "https://files.pythonhosted.org/packages/c4/10/a4a1f2fba205a9462e36e708ba37e5ac95f4987a0f1f8fd23f0bf1fc3b0f/wrapt-2.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7b822c61ed04ee6ad64bc90d13368ad6eb094db54883b5dde2182f67a7f22c0", size = 61797, upload-time = "2025-11-07T00:44:37.22Z" }, - { url = "https://files.pythonhosted.org/packages/12/db/99ba5c37cf1c4fad35349174f1e38bd8d992340afc1ff27f526729b98986/wrapt-2.0.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7164a55f5e83a9a0b031d3ffab4d4e36bbec42e7025db560f225489fa929e509", size = 120470, upload-time = "2025-11-07T00:44:39.425Z" }, - { url = "https://files.pythonhosted.org/packages/30/3f/a1c8d2411eb826d695fc3395a431757331582907a0ec59afce8fe8712473/wrapt-2.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e60690ba71a57424c8d9ff28f8d006b7ad7772c22a4af432188572cd7fa004a1", size = 122851, upload-time = "2025-11-07T00:44:40.582Z" }, - { url = "https://files.pythonhosted.org/packages/b3/8d/72c74a63f201768d6a04a8845c7976f86be6f5ff4d74996c272cefc8dafc/wrapt-2.0.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3cd1a4bd9a7a619922a8557e1318232e7269b5fb69d4ba97b04d20450a6bf970", size = 117433, upload-time = "2025-11-07T00:44:38.313Z" }, - { url = "https://files.pythonhosted.org/packages/c7/5a/df37cf4042cb13b08256f8e27023e2f9b3d471d553376616591bb99bcb31/wrapt-2.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b4c2e3d777e38e913b8ce3a6257af72fb608f86a1df471cb1d4339755d0a807c", size = 121280, upload-time = "2025-11-07T00:44:41.69Z" }, - { url = "https://files.pythonhosted.org/packages/54/34/40d6bc89349f9931e1186ceb3e5fbd61d307fef814f09fbbac98ada6a0c8/wrapt-2.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3d366aa598d69416b5afedf1faa539fac40c1d80a42f6b236c88c73a3c8f2d41", size = 116343, upload-time = "2025-11-07T00:44:43.013Z" }, - { url = "https://files.pythonhosted.org/packages/70/66/81c3461adece09d20781dee17c2366fdf0cb8754738b521d221ca056d596/wrapt-2.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c235095d6d090aa903f1db61f892fffb779c1eaeb2a50e566b52001f7a0f66ed", size = 119650, upload-time = "2025-11-07T00:44:44.523Z" }, - { url = "https://files.pythonhosted.org/packages/46/3a/d0146db8be8761a9e388cc9cc1c312b36d583950ec91696f19bbbb44af5a/wrapt-2.0.1-cp314-cp314-win32.whl", hash = "sha256:bfb5539005259f8127ea9c885bdc231978c06b7a980e63a8a61c8c4c979719d0", size = 58701, upload-time = "2025-11-07T00:44:48.277Z" }, - { url = "https://files.pythonhosted.org/packages/1a/38/5359da9af7d64554be63e9046164bd4d8ff289a2dd365677d25ba3342c08/wrapt-2.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:4ae879acc449caa9ed43fc36ba08392b9412ee67941748d31d94e3cedb36628c", size = 60947, upload-time = "2025-11-07T00:44:46.086Z" }, - { url = "https://files.pythonhosted.org/packages/aa/3f/96db0619276a833842bf36343685fa04f987dd6e3037f314531a1e00492b/wrapt-2.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:8639b843c9efd84675f1e100ed9e99538ebea7297b62c4b45a7042edb84db03e", size = 59359, upload-time = "2025-11-07T00:44:47.164Z" }, - { url = "https://files.pythonhosted.org/packages/71/49/5f5d1e867bf2064bf3933bc6cf36ade23505f3902390e175e392173d36a2/wrapt-2.0.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:9219a1d946a9b32bb23ccae66bdb61e35c62773ce7ca6509ceea70f344656b7b", size = 82031, upload-time = "2025-11-07T00:44:49.4Z" }, - { url = "https://files.pythonhosted.org/packages/2b/89/0009a218d88db66ceb83921e5685e820e2c61b59bbbb1324ba65342668bc/wrapt-2.0.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fa4184e74197af3adad3c889a1af95b53bb0466bced92ea99a0c014e48323eec", size = 62952, upload-time = "2025-11-07T00:44:50.74Z" }, - { url = "https://files.pythonhosted.org/packages/ae/18/9b968e920dd05d6e44bcc918a046d02afea0fb31b2f1c80ee4020f377cbe/wrapt-2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c5ef2f2b8a53b7caee2f797ef166a390fef73979b15778a4a153e4b5fedce8fa", size = 63688, upload-time = "2025-11-07T00:44:52.248Z" }, - { url = "https://files.pythonhosted.org/packages/a6/7d/78bdcb75826725885d9ea26c49a03071b10c4c92da93edda612910f150e4/wrapt-2.0.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e042d653a4745be832d5aa190ff80ee4f02c34b21f4b785745eceacd0907b815", size = 152706, upload-time = "2025-11-07T00:44:54.613Z" }, - { url = "https://files.pythonhosted.org/packages/dd/77/cac1d46f47d32084a703df0d2d29d47e7eb2a7d19fa5cbca0e529ef57659/wrapt-2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2afa23318136709c4b23d87d543b425c399887b4057936cd20386d5b1422b6fa", size = 158866, upload-time = "2025-11-07T00:44:55.79Z" }, - { url = "https://files.pythonhosted.org/packages/8a/11/b521406daa2421508903bf8d5e8b929216ec2af04839db31c0a2c525eee0/wrapt-2.0.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c72328f668cf4c503ffcf9434c2b71fdd624345ced7941bc6693e61bbe36bef", size = 146148, upload-time = "2025-11-07T00:44:53.388Z" }, - { url = "https://files.pythonhosted.org/packages/0c/c0/340b272bed297baa7c9ce0c98ef7017d9c035a17a6a71dce3184b8382da2/wrapt-2.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3793ac154afb0e5b45d1233cb94d354ef7a983708cc3bb12563853b1d8d53747", size = 155737, upload-time = "2025-11-07T00:44:56.971Z" }, - { url = "https://files.pythonhosted.org/packages/f3/93/bfcb1fb2bdf186e9c2883a4d1ab45ab099c79cbf8f4e70ea453811fa3ea7/wrapt-2.0.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fec0d993ecba3991645b4857837277469c8cc4c554a7e24d064d1ca291cfb81f", size = 144451, upload-time = "2025-11-07T00:44:58.515Z" }, - { url = "https://files.pythonhosted.org/packages/d2/6b/dca504fb18d971139d232652656180e3bd57120e1193d9a5899c3c0b7cdd/wrapt-2.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:949520bccc1fa227274da7d03bf238be15389cd94e32e4297b92337df9b7a349", size = 150353, upload-time = "2025-11-07T00:44:59.753Z" }, - { url = "https://files.pythonhosted.org/packages/1d/f6/a1de4bd3653afdf91d250ca5c721ee51195df2b61a4603d4b373aa804d1d/wrapt-2.0.1-cp314-cp314t-win32.whl", hash = "sha256:be9e84e91d6497ba62594158d3d31ec0486c60055c49179edc51ee43d095f79c", size = 60609, upload-time = "2025-11-07T00:45:03.315Z" }, - { url = "https://files.pythonhosted.org/packages/01/3a/07cd60a9d26fe73efead61c7830af975dfdba8537632d410462672e4432b/wrapt-2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:61c4956171c7434634401db448371277d07032a81cc21c599c22953374781395", size = 64038, upload-time = "2025-11-07T00:45:00.948Z" }, - { url = "https://files.pythonhosted.org/packages/41/99/8a06b8e17dddbf321325ae4eb12465804120f699cd1b8a355718300c62da/wrapt-2.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:35cdbd478607036fee40273be8ed54a451f5f23121bd9d4be515158f9498f7ad", size = 60634, upload-time = "2025-11-07T00:45:02.087Z" }, - { url = "https://files.pythonhosted.org/packages/15/d1/b51471c11592ff9c012bd3e2f7334a6ff2f42a7aed2caffcf0bdddc9cb89/wrapt-2.0.1-py3-none-any.whl", hash = "sha256:4d2ce1bf1a48c5277d7969259232b57645aae5686dba1eaeade39442277afbca", size = 44046, upload-time = "2025-11-07T00:45:32.116Z" }, -] - [[package]] name = "yarg" version = "0.1.9" diff --git a/docker-compose.yml b/docker-compose.yml index 637f1dfa..45c3c228 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,6 @@ services: mirofish: - image: ghcr.io/666ghj/mirofish:latest - # 加速镜像(如拉取缓慢可替换上方地址) - # image: ghcr.nju.edu.cn/666ghj/mirofish:latest + image: ${MIROFISH_IMAGE:-ghcr.io/666ghj/mirofish:latest} container_name: mirofish env_file: - .env @@ -11,4 +9,4 @@ services: - "5001:5001" restart: unless-stopped volumes: - - ./backend/uploads:/app/backend/uploads \ No newline at end of file + - ./backend/uploads:/app/backend/uploads diff --git a/docs/upstream-all-state.json b/docs/upstream-all-state.json new file mode 100644 index 00000000..7722f144 --- /dev/null +++ b/docs/upstream-all-state.json @@ -0,0 +1,6964 @@ +{ + "repo": "666ghj/MiroFish", + "state": "all", + "captured_at": "2026-03-12T04:03:26.339986+00:00", + "generated_at": "2026-03-12T04:03:26.339986+00:00", + "refreshed_at": "2026-03-12T04:03:26.339986+00:00", + "coverage_map_path": "docs/upstream-coverage.json", + "counts": { + "issues": { + "open": 46, + "closed": 50 + }, + "pull_requests": { + "open": 40, + "closed": 14 + }, + "mirrored_pull_requests": { + "mirrored": 54, + "not_mirrored": 0 + }, + "mirrored_issues": { + "mirrored": 96, + "not_mirrored": 0 + } + }, + "issues": [ + { + "number": 159, + "title": "太消耗zep了,为啥不考虑自建库呢?", + "url": "https://github.com/666ghj/MiroFish/issues/159", + "state": "open", + "created_at": "2026-03-12T03:20:17Z", + "updated_at": "2026-03-12T03:30:23Z", + "closed_at": null, + "labels": [ + "enhancement" + ], + "author": "codetsang", + "body_excerpt": "zep的额度太低了,要真正进行分析,需要大量的Episode。能否考虑基于其他开源方案,重写zep部分?", + "comment_count": 1, + "recent_comments": [ + { + "author": "chrischeng192", + "created_at": "2026-03-12T03:30:23Z", + "updated_at": "2026-03-12T03:30:23Z", + "url": "https://github.com/666ghj/MiroFish/issues/159#issuecomment-4043666300", + "body_excerpt": "你暂时可以看看[这里](https://github.com/666ghj/MiroFish/issues/56)" + } + ], + "local_coverage": { + "number": 159, + "status": "tracked", + "summary": "Tracked under beads issue `mirofish-zx6p`: another upstream request for a self-hosted or non-Zep graph backend is preserved locally, but implementing it safely still requires the broader graph-backend abstraction work already tracked under `mirofish-8eg` instead of wiring an ad hoc replacement into the current graph/simulation pipeline.", + "local_refs": [ + ".beads/issues.jsonl", + "docs/upstream-triage.md", + "https://github.com/ivanzud/MiroFish/issues/97" + ], + "validation": [ + "tracking only" + ], + "notes": "Mirrored into fork issue #97 on March 12, 2026. This request overlaps the existing non-Zep backend and self-hosted graph follow-ups from upstream issues #55, #76, #106, and #156." + }, + "local_status": "tracked", + "local_summary": "Tracked under beads issue `mirofish-zx6p`: another upstream request for a self-hosted or non-Zep graph backend is preserved locally, but implementing it safely still requires the broader graph-backend abstraction work already tracked under `mirofish-8eg` instead of wiring an ad hoc replacement into the current graph/simulation pipeline.", + "triage_status": "tracked", + "summary": "Tracked under beads issue `mirofish-zx6p`: another upstream request for a self-hosted or non-Zep graph backend is preserved locally, but implementing it safely still requires the broader graph-backend abstraction work already tracked under `mirofish-8eg` instead of wiring an ad hoc replacement into the current graph/simulation pipeline.", + "coverage_status": "tracked", + "coverage_summary": "Tracked under beads issue `mirofish-zx6p`: another upstream request for a self-hosted or non-Zep graph backend is preserved locally, but implementing it safely still requires the broader graph-backend abstraction work already tracked under `mirofish-8eg` instead of wiring an ad hoc replacement into the current graph/simulation pipeline.", + "fork_issue_mirrored": true, + "fork_issue_number": 97, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/97" + }, + { + "number": 158, + "title": "Are there any predictions that have been verified by subsequent events?", + "url": "https://github.com/666ghj/MiroFish/issues/158", + "state": "open", + "created_at": "2026-03-12T01:50:37Z", + "updated_at": "2026-03-12T03:24:05Z", + "closed_at": null, + "labels": [ + "question" + ], + "author": "DuinoDu", + "body_excerpt": "Awesome idea! I am wondering are there any predictions that have been verified by subsequent events?", + "comment_count": 1, + "recent_comments": [ + { + "author": "codetsang", + "created_at": "2026-03-12T03:24:05Z", + "updated_at": "2026-03-12T03:24:05Z", + "url": "https://github.com/666ghj/MiroFish/issues/158#issuecomment-4043646147", + "body_excerpt": "Not yet? Maybe you should give it a try and validate the results. BTW, this is a prediction tool, so there are many uncertainties involved. It should be used more as an analysis or decision-support tool rather than a strict predictor." + } + ], + "local_coverage": { + "number": 158, + "status": "partial", + "summary": "README.md, README-EN.md, README-RU.md, README-KO.md, and README-JA.md now document a repo-native forecast verification workflow, Step 4 surfaces both the stable `report_id` and `simulation_id` with direct copy actions, the homepage history modal keeps those same references together for later review, and both views now also copy a single structured verification bundle so users can preserve the paired references in one paste. Exported Step 4 Markdown still embeds the report/simulation/graph references directly in the file header, and it now also includes stable local report paths plus a localized manual verification checklist so the saved artifact stays actionable outside the UI. Users can export that Markdown from both Step 4 and the saved-history modal or reuse the files under `backend/uploads/reports/<report_id>/` for later comparison against real-world outcomes. MiroFish still does not ship an automatic ground-truth ingester or scoring pipeline, so full backtesting remains tracked under beads issue `mirofish-gytl`.", + "local_refs": [ + ".beads/issues.jsonl", + "README-EN.md", + "README-JA.md", + "README-KO.md", + "README.md", + "README-RU.md", + "backend/app/services/report_agent.py", + "backend/tests/test_report_agent.py", + "docs/upstream-triage.md", + "frontend/src/components/HistoryDatabase.vue", + "frontend/src/components/historyReportDownload.js", + "frontend/src/components/Step4Report.vue", + "frontend/src/components/verificationBundle.js", + "frontend/src/i18n/locales/en.js", + "frontend/src/i18n/locales/zh.js", + "frontend/tests/historyReportDownload.test.mjs", + "frontend/tests/verificationBundle.test.mjs", + "https://github.com/ivanzud/MiroFish/issues/95" + ], + "validation": [ + "cd backend && uv run pytest -q tests/test_report_agent.py -k \"reference_block or embeds_reference_block\"", + "uv run --project backend pytest -q backend/tests/test_report_agent.py backend/tests/test_report_api_i18n.py", + "python3 -m compileall backend/app/services/report_agent.py backend/tests/test_report_agent.py backend/tests/test_report_api_i18n.py", + "bash ./scripts/test_backend_lite.sh", + "npm --prefix frontend test", + "npm --prefix frontend run build", + "README verification" + ], + "notes": "Mirrored into fork issue #95 on March 12, 2026. The repo now documents how to preserve and revisit forecast evidence across the Chinese, English, Russian, Korean, and Japanese README set, exposes copyable report/simulation IDs in Step 4, surfaces the same references plus a direct Markdown export action in the history modal for later reuse, adds a one-click verification bundle copy in both Step 4 and history so those references can be preserved together, and makes the exported Markdown self-identifying with storage paths plus a manual checklist so the verification evidence survives outside the UI. It still does not automate outcome ingestion or accuracy scoring." + }, + "local_status": "partial", + "local_summary": "README.md, README-EN.md, README-RU.md, README-KO.md, and README-JA.md now document a repo-native forecast verification workflow, Step 4 surfaces both the stable `report_id` and `simulation_id` with direct copy actions, the homepage history modal keeps those same references together for later review, and both views now also copy a single structured verification bundle so users can preserve the paired references in one paste. Exported Step 4 Markdown still embeds the report/simulation/graph references directly in the file header, and it now also includes stable local report paths plus a localized manual verification checklist so the saved artifact stays actionable outside the UI. Users can export that Markdown from both Step 4 and the saved-history modal or reuse the files under `backend/uploads/reports/<report_id>/` for later comparison against real-world outcomes. MiroFish still does not ship an automatic ground-truth ingester or scoring pipeline, so full backtesting remains tracked under beads issue `mirofish-gytl`.", + "triage_status": "partial", + "summary": "README.md, README-EN.md, README-RU.md, README-KO.md, and README-JA.md now document a repo-native forecast verification workflow, Step 4 surfaces both the stable `report_id` and `simulation_id` with direct copy actions, the homepage history modal keeps those same references together for later review, and both views now also copy a single structured verification bundle so users can preserve the paired references in one paste. Exported Step 4 Markdown still embeds the report/simulation/graph references directly in the file header, and it now also includes stable local report paths plus a localized manual verification checklist so the saved artifact stays actionable outside the UI. Users can export that Markdown from both Step 4 and the saved-history modal or reuse the files under `backend/uploads/reports/<report_id>/` for later comparison against real-world outcomes. MiroFish still does not ship an automatic ground-truth ingester or scoring pipeline, so full backtesting remains tracked under beads issue `mirofish-gytl`.", + "coverage_status": "partial", + "coverage_summary": "README.md, README-EN.md, README-RU.md, README-KO.md, and README-JA.md now document a repo-native forecast verification workflow, Step 4 surfaces both the stable `report_id` and `simulation_id` with direct copy actions, the homepage history modal keeps those same references together for later review, and both views now also copy a single structured verification bundle so users can preserve the paired references in one paste. Exported Step 4 Markdown still embeds the report/simulation/graph references directly in the file header, and it now also includes stable local report paths plus a localized manual verification checklist so the saved artifact stays actionable outside the UI. Users can export that Markdown from both Step 4 and the saved-history modal or reuse the files under `backend/uploads/reports/<report_id>/` for later comparison against real-world outcomes. MiroFish still does not ship an automatic ground-truth ingester or scoring pipeline, so full backtesting remains tracked under beads issue `mirofish-gytl`.", + "fork_issue_mirrored": true, + "fork_issue_number": 95, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/95" + }, + { + "number": 157, + "title": "如何删除不想要的记录", + "url": "https://github.com/666ghj/MiroFish/issues/157", + "state": "open", + "created_at": "2026-03-12T01:49:07Z", + "updated_at": "2026-03-12T01:51:15Z", + "closed_at": null, + "labels": [ + "question" + ], + "author": "Axing93", + "body_excerpt": "比如我想删除 <img width=\"1835\" height=\"775\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/12332bbc-f309-497b-a352-f0d15289042e\" />这两个,怎么删除呢", + "comment_count": 0, + "recent_comments": [], + "local_coverage": { + "number": 157, + "status": "covered", + "summary": "Homepage history now supports repo-native deletion of unwanted local records. The backend exposes `DELETE /api/simulation/history/<simulation_id>` to remove a simulation's persisted local directory, cascade-delete its attached local reports, and prune the project metadata when no sibling simulations remain, while refusing deletion for active runs. The history modal now exposes a localized delete action that calls that endpoint directly.", + "local_refs": [ + ".beads/issues.jsonl", + "backend/app/api/simulation.py", + "backend/app/i18n.py", + "backend/app/services/simulation_manager.py", + "backend/tests/test_simulation_api_i18n.py", + "frontend/src/api/simulation.js", + "frontend/src/components/HistoryDatabase.vue", + "frontend/src/i18n/locales/en.js", + "frontend/src/i18n/locales/zh.js", + "https://github.com/ivanzud/MiroFish/issues/96" + ], + "validation": [ + "cd backend && uv run pytest -q tests/test_simulation_api_i18n.py", + "bash ./scripts/test_backend_lite.sh", + "npm --prefix frontend run build" + ], + "notes": "Mirrored into fork issue #96 on March 12, 2026. This is a repo-native local-history cleanup flow; it intentionally avoids deleting upstream or Zep-hosted graph data." + }, + "local_status": "covered", + "local_summary": "Homepage history now supports repo-native deletion of unwanted local records. The backend exposes `DELETE /api/simulation/history/<simulation_id>` to remove a simulation's persisted local directory, cascade-delete its attached local reports, and prune the project metadata when no sibling simulations remain, while refusing deletion for active runs. The history modal now exposes a localized delete action that calls that endpoint directly.", + "triage_status": "covered", + "summary": "Homepage history now supports repo-native deletion of unwanted local records. The backend exposes `DELETE /api/simulation/history/<simulation_id>` to remove a simulation's persisted local directory, cascade-delete its attached local reports, and prune the project metadata when no sibling simulations remain, while refusing deletion for active runs. The history modal now exposes a localized delete action that calls that endpoint directly.", + "coverage_status": "covered", + "coverage_summary": "Homepage history now supports repo-native deletion of unwanted local records. The backend exposes `DELETE /api/simulation/history/<simulation_id>` to remove a simulation's persisted local directory, cascade-delete its attached local reports, and prune the project metadata when no sibling simulations remain, while refusing deletion for active runs. The history modal now exposes a localized delete action that calls that endpoint directly.", + "fork_issue_mirrored": true, + "fork_issue_number": 96, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/96" + }, + { + "number": 156, + "title": "能不能不要画zep图?我只要推演和角色互动", + "url": "https://github.com/666ghj/MiroFish/issues/156", + "state": "open", + "created_at": "2026-03-12T01:33:58Z", + "updated_at": "2026-03-12T01:36:11Z", + "closed_at": null, + "labels": [ + "enhancement" + ], + "author": "yiziwu-dm", + "body_excerpt": "zep免费额度轻松就用完了,然后流程卡4/5在生成报告上面", + "comment_count": 0, + "recent_comments": [], + "local_coverage": { + "number": 156, + "status": "tracked", + "summary": "Tracked under beads issue `mirofish-gd5z`: upstream wants a simulation/report workflow that avoids Zep graph dependency entirely. The config-status payload and frontend backend diagnostics now expose a capability matrix that separates the direct `OPENAI_*` / Codex-compatible LLM path from Zep-gated Step 1 graph build and graph-backed Step 4 tooling, `/api/report/generate` now fails early with the same structured backend-config payload instead of launching a doomed async Step 4 task, and the frontend now also exposes a simulation-only Step 5 route plus Step 3/Step 4 CTAs so users can continue directly into role interaction without a report when only `ZEP_API_KEY` is missing. Full non-Zep simulation-only execution still needs a dedicated backend-architecture change.", + "local_refs": [ + ".beads/issues.jsonl", + "backend/app/api/report.py", + "backend/app/config.py", + "backend/tests/test_report_api_i18n.py", + "backend/tests/test_config.py", + "backend/tests/test_print_config_status.py", + "frontend/src/components/interactionRoute.js", + "frontend/src/components/apiConfigDiagnostics.js", + "frontend/src/components/Step3Simulation.vue", + "frontend/src/components/Step4Report.vue", + "frontend/src/components/Step5Interaction.vue", + "frontend/src/i18n/locales/en.js", + "frontend/src/i18n/locales/zh.js", + "frontend/tests/apiConfigDiagnostics.test.mjs", + "frontend/tests/interactionRoute.test.mjs", + "frontend/src/router/index.js", + "frontend/src/views/InteractionView.vue", + "README.md", + "README-EN.md", + "https://github.com/ivanzud/MiroFish/issues/94" + ], + "validation": [ + "cd backend && uv run pytest -q tests/test_config.py tests/test_print_config_status.py", + "uv run --project backend pytest -q backend/tests/test_report_api_i18n.py", + "npm --prefix frontend test -- --runInBand apiConfigDiagnostics.test.mjs", + "npm --prefix frontend test", + "npm --prefix frontend run build", + "env -i PATH=\"$PATH\" HOME=\"$HOME\" TERM=\"$TERM\" OPENAI_API_KEY=codex-test-key OPENAI_API_BASE_URL=https://codex.example.test/v1 OPENAI_MODEL=gpt-4.1-mini ZEP_API_KEY=zep-test-key SECRET_KEY=test-secret npm run check:backend-config -- --compact", + "env -i PATH=\"$PATH\" HOME=\"$HOME\" TERM=\"$TERM\" OPENAI_API_KEY=codex-test-key OPENAI_API_BASE_URL=https://codex.example.test/v1 OPENAI_MODEL=gpt-4.1-mini SECRET_KEY=test-secret npm run check:backend-config -- --compact", + "bash ./scripts/test_backend_lite.sh" + ], + "notes": "Mirrored into fork issue #94 on March 12, 2026. The landed capability matrix makes it explicit that direct OpenAI-compatible LLM wiring can still be valid even when Step 1 / Step 4 remain blocked on Zep, and that Step 5 interaction is still available on an existing simulation environment. This branch now also returns that same non-sensitive config payload directly from `/api/report/generate`, so Step 4 no longer starts a doomed async task when only the direct LLM path is configured, and the frontend now gives users a repo-native escape hatch into `/interaction/simulation/<simulation_id>` from both Step 3 preflight failures and Step 4 failed-report screens." + }, + "local_status": "tracked", + "local_summary": "Tracked under beads issue `mirofish-gd5z`: upstream wants a simulation/report workflow that avoids Zep graph dependency entirely. The config-status payload and frontend backend diagnostics now expose a capability matrix that separates the direct `OPENAI_*` / Codex-compatible LLM path from Zep-gated Step 1 graph build and graph-backed Step 4 tooling, `/api/report/generate` now fails early with the same structured backend-config payload instead of launching a doomed async Step 4 task, and the frontend now also exposes a simulation-only Step 5 route plus Step 3/Step 4 CTAs so users can continue directly into role interaction without a report when only `ZEP_API_KEY` is missing. Full non-Zep simulation-only execution still needs a dedicated backend-architecture change.", + "triage_status": "tracked", + "summary": "Tracked under beads issue `mirofish-gd5z`: upstream wants a simulation/report workflow that avoids Zep graph dependency entirely. The config-status payload and frontend backend diagnostics now expose a capability matrix that separates the direct `OPENAI_*` / Codex-compatible LLM path from Zep-gated Step 1 graph build and graph-backed Step 4 tooling, `/api/report/generate` now fails early with the same structured backend-config payload instead of launching a doomed async Step 4 task, and the frontend now also exposes a simulation-only Step 5 route plus Step 3/Step 4 CTAs so users can continue directly into role interaction without a report when only `ZEP_API_KEY` is missing. Full non-Zep simulation-only execution still needs a dedicated backend-architecture change.", + "coverage_status": "tracked", + "coverage_summary": "Tracked under beads issue `mirofish-gd5z`: upstream wants a simulation/report workflow that avoids Zep graph dependency entirely. The config-status payload and frontend backend diagnostics now expose a capability matrix that separates the direct `OPENAI_*` / Codex-compatible LLM path from Zep-gated Step 1 graph build and graph-backed Step 4 tooling, `/api/report/generate` now fails early with the same structured backend-config payload instead of launching a doomed async Step 4 task, and the frontend now also exposes a simulation-only Step 5 route plus Step 3/Step 4 CTAs so users can continue directly into role interaction without a report when only `ZEP_API_KEY` is missing. Full non-Zep simulation-only execution still needs a dedicated backend-architecture change.", + "fork_issue_mirrored": true, + "fork_issue_number": 94, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/94" + }, + { + "number": 154, + "title": "Profile serialization crashes when LLM returns structured bio/persona fields", + "url": "https://github.com/666ghj/MiroFish/issues/154", + "state": "open", + "created_at": "2026-03-12T00:26:01Z", + "updated_at": "2026-03-12T00:32:38Z", + "closed_at": null, + "labels": [], + "author": "ygh1254", + "body_excerpt": "## Summary When profile generation returns structured JSON objects for fields like `bio`, `persona`, or `country`, MiroFish can fail during profile serialization before config generation starts. ## Reproduction context Observed on a live run with: - simulation_id: `sim_e69a946b6158` - graph_id: `mirofish_a39b5f10127f4744` - entities_count: `91` - status in state file: `failed` - error in state fi…", + "comment_count": 1, + "recent_comments": [ + { + "author": "dosubot[bot]", + "created_at": "2026-03-12T00:32:38Z", + "updated_at": "2026-03-12T00:32:38Z", + "url": "https://github.com/666ghj/MiroFish/issues/154#issuecomment-4043093778", + "body_excerpt": "<!-- Greeting --> Hi @ygh1254! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the MiroFish team. <!-- Answer --> Your analysis is spot on. Looking at the code, the root cause is exactly as you described — the [`OasisAgentProfile`…" + } + ], + "local_coverage": { + "number": 154, + "status": "covered", + "summary": "Profile serialization now tolerates structured LLM output instead of crashing when `bio`, `persona`, `country`, `profession`, or `interested_topics` arrive as dict/list values. `OasisAgentProfile` normalizes those mixed types at construction time and the Reddit/Twitter serializers defensively coerce them again before slicing or string replacement, so simulation preparation no longer fails during profile save with `KeyError: slice(None, 150, None)`.", + "local_refs": [ + ".beads/issues.jsonl", + "backend/app/services/oasis_profile_generator.py", + "backend/tests/test_openai_compat_services.py", + "https://github.com/ivanzud/MiroFish/issues/93" + ], + "validation": [ + "cd backend && uv run pytest -q tests/test_openai_compat_services.py -k \"structured_fields or save_profiles_defaults_country_by_locale or save_twitter_profiles_tolerates_structured_fields\"", + "bash ./scripts/test_backend_lite.sh" + ], + "notes": "Mirrored into fork issue #93 on March 12, 2026. This is a repo-native fix for the newly reported upstream serialization crash rather than a PR cherry-pick." + }, + "local_status": "covered", + "local_summary": "Profile serialization now tolerates structured LLM output instead of crashing when `bio`, `persona`, `country`, `profession`, or `interested_topics` arrive as dict/list values. `OasisAgentProfile` normalizes those mixed types at construction time and the Reddit/Twitter serializers defensively coerce them again before slicing or string replacement, so simulation preparation no longer fails during profile save with `KeyError: slice(None, 150, None)`.", + "triage_status": "covered", + "summary": "Profile serialization now tolerates structured LLM output instead of crashing when `bio`, `persona`, `country`, `profession`, or `interested_topics` arrive as dict/list values. `OasisAgentProfile` normalizes those mixed types at construction time and the Reddit/Twitter serializers defensively coerce them again before slicing or string replacement, so simulation preparation no longer fails during profile save with `KeyError: slice(None, 150, None)`.", + "coverage_status": "covered", + "coverage_summary": "Profile serialization now tolerates structured LLM output instead of crashing when `bio`, `persona`, `country`, `profession`, or `interested_topics` arrive as dict/list values. `OasisAgentProfile` normalizes those mixed types at construction time and the Reddit/Twitter serializers defensively coerce them again before slicing or string replacement, so simulation preparation no longer fails during profile save with `KeyError: slice(None, 150, None)`.", + "fork_issue_mirrored": true, + "fork_issue_number": 93, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/93" + }, + { + "number": 153, + "title": "npm run setup:all安装时一直报 pillow` (v10.3.0) 的错", + "url": "https://github.com/666ghj/MiroFish/issues/153", + "state": "open", + "created_at": "2026-03-11T18:39:28Z", + "updated_at": "2026-03-11T18:41:35Z", + "closed_at": null, + "labels": [ + "question" + ], + "author": "heiheiheibj", + "body_excerpt": "Resolved 188 packages in 5.27s Built mirofish-backend @ file:///D:/MiroFish/backend x Failed to build `pillow==10.3.0` |-> The build backend returned an error `-> Call to `backend.build_wheel` failed (exit code: 1) [stderr] Traceback (most recent call last): File \"<string>\", line 14, in <module> requires = get_requires_for_build({}) File \"C:\\Users\\Administrator\\AppData\\Local\\uv\\cache\\builds-v0\\.t…", + "comment_count": 0, + "recent_comments": [], + "local_coverage": { + "number": 153, + "status": "covered", + "summary": "The current branch no longer reproduces a `pillow` build during the default `npm run setup:all` core install path. `setup:backend` now maps to a plain `uv sync` of the core graph/report/OpenAI-compatible backend dependencies, while the heavyweight simulation runtime remains behind the separate `setup:backend:simulation` entrypoint. A Windows + Python 3.13 dry-run of `uv sync --frozen` against the current lockfile does not attempt to install `pillow` at all, and the optional simulation lock now resolves `pillow==10.4.0` instead of `10.3.0`.", + "local_refs": [ + "package.json", + "README.md", + "README-EN.md", + "scripts/setup_backend_simulation.py", + "backend/uv.lock" + ], + "validation": [ + "cd backend && uv sync --frozen --python 3.13 --python-platform windows --dry-run --output-format json", + "cd backend && uv sync --extra simulation --frozen --dry-run", + "triage diff review" + ], + "notes": "Mirrored into fork issue #92 on March 11, 2026. If the reporter still sees `pillow==10.3.0` during `setup:all`, they are likely on an older checkout. The current core path avoids Pillow entirely and the optional simulation path now locks Pillow to `10.4.0`." + }, + "local_status": "covered", + "local_summary": "The current branch no longer reproduces a `pillow` build during the default `npm run setup:all` core install path. `setup:backend` now maps to a plain `uv sync` of the core graph/report/OpenAI-compatible backend dependencies, while the heavyweight simulation runtime remains behind the separate `setup:backend:simulation` entrypoint. A Windows + Python 3.13 dry-run of `uv sync --frozen` against the current lockfile does not attempt to install `pillow` at all, and the optional simulation lock now resolves `pillow==10.4.0` instead of `10.3.0`.", + "triage_status": "covered", + "summary": "The current branch no longer reproduces a `pillow` build during the default `npm run setup:all` core install path. `setup:backend` now maps to a plain `uv sync` of the core graph/report/OpenAI-compatible backend dependencies, while the heavyweight simulation runtime remains behind the separate `setup:backend:simulation` entrypoint. A Windows + Python 3.13 dry-run of `uv sync --frozen` against the current lockfile does not attempt to install `pillow` at all, and the optional simulation lock now resolves `pillow==10.4.0` instead of `10.3.0`.", + "coverage_status": "covered", + "coverage_summary": "The current branch no longer reproduces a `pillow` build during the default `npm run setup:all` core install path. `setup:backend` now maps to a plain `uv sync` of the core graph/report/OpenAI-compatible backend dependencies, while the heavyweight simulation runtime remains behind the separate `setup:backend:simulation` entrypoint. A Windows + Python 3.13 dry-run of `uv sync --frozen` against the current lockfile does not attempt to install `pillow` at all, and the optional simulation lock now resolves `pillow==10.4.0` instead of `10.3.0`.", + "fork_issue_mirrored": true, + "fork_issue_number": 92, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/92" + }, + { + "number": 150, + "title": "Bug: Hardcoded 'reddit' platform default causes silent data loss for Twitter-only simulations", + "url": "https://github.com/666ghj/MiroFish/issues/150", + "state": "open", + "created_at": "2026-03-11T17:36:38Z", + "updated_at": "2026-03-11T17:49:29Z", + "closed_at": null, + "labels": [], + "author": "karesansui-u", + "body_excerpt": "## Summary When a simulation is created with Twitter-only configuration (`enable_reddit=false`), all data retrieval APIs silently return empty results because they default to looking up Reddit data. No error is raised — the user sees an empty UI with no indication of what went wrong. ## Root Cause The platform parameter defaults to `'reddit'` in 11+ locations across the codebase. When a Twitter-o…", + "comment_count": 0, + "recent_comments": [], + "local_coverage": { + "number": 150, + "status": "covered", + "summary": "Simulation data retrieval now resolves the active platform from `SimulationState` instead of silently defaulting to Reddit when Twitter/X is the only enabled platform, and `SimulationManager.get_profiles()` now reads Twitter profile CSV files correctly as well. That prevents empty profile/post responses in Twitter-only simulations even when older callers still pass the historical `reddit` default.", + "local_refs": [ + ".beads/issues.jsonl", + "backend/app/services/simulation_manager.py", + "backend/app/api/simulation.py", + "backend/tests/test_simulation_service_i18n.py", + "backend/tests/test_simulation_api_i18n.py", + "https://github.com/ivanzud/MiroFish/issues/91" + ], + "validation": [ + "cd backend && uv run pytest -q tests/test_simulation_service_i18n.py tests/test_simulation_api_i18n.py", + "python3 -m compileall backend/app/services/simulation_manager.py backend/app/api/simulation.py" + ], + "notes": "Mirrored into fork issue #91 on March 11, 2026. The fix keeps legacy callers working by falling back to the sole enabled platform instead of assuming Reddit." + }, + "local_status": "covered", + "local_summary": "Simulation data retrieval now resolves the active platform from `SimulationState` instead of silently defaulting to Reddit when Twitter/X is the only enabled platform, and `SimulationManager.get_profiles()` now reads Twitter profile CSV files correctly as well. That prevents empty profile/post responses in Twitter-only simulations even when older callers still pass the historical `reddit` default.", + "triage_status": "covered", + "summary": "Simulation data retrieval now resolves the active platform from `SimulationState` instead of silently defaulting to Reddit when Twitter/X is the only enabled platform, and `SimulationManager.get_profiles()` now reads Twitter profile CSV files correctly as well. That prevents empty profile/post responses in Twitter-only simulations even when older callers still pass the historical `reddit` default.", + "coverage_status": "covered", + "coverage_summary": "Simulation data retrieval now resolves the active platform from `SimulationState` instead of silently defaulting to Reddit when Twitter/X is the only enabled platform, and `SimulationManager.get_profiles()` now reads Twitter profile CSV files correctly as well. That prevents empty profile/post responses in Twitter-only simulations even when older callers still pass the historical `reddit` default.", + "fork_issue_mirrored": true, + "fork_issue_number": 91, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/91" + }, + { + "number": 148, + "title": "Request failed with status code 504", + "url": "https://github.com/666ghj/MiroFish/issues/148", + "state": "closed", + "created_at": "2026-03-11T16:46:05Z", + "updated_at": "2026-03-11T17:00:58Z", + "closed_at": "2026-03-11T17:00:58Z", + "labels": [ + "LLM API" + ], + "author": "darfy22", + "body_excerpt": "完成report后,进入深度对话,在Interactive Tools中,与Report agent对话是正常的,但是与世界中任意个体对话则报错:“抱歉,发生了错误: Request failed with status code 504“。", + "comment_count": 2, + "recent_comments": [ + { + "author": "dosubot[bot]", + "created_at": "2026-03-11T16:49:18Z", + "updated_at": "2026-03-11T16:49:18Z", + "url": "https://github.com/666ghj/MiroFish/issues/148#issuecomment-4040617202", + "body_excerpt": "<!-- Answer --> 这个504错误是因为**与世界个体对话需要模拟环境保持运行状态**,而Report Agent对话则不需要。 具体原因: - **Report Agent对话**使用的是 `/api/report/chat` 端点,它[独立创建ReportAgent实例,不依赖模拟环境](https://github.com/666ghj/MiroFish/blob/985f89f49acbb44ee14d9d680682c741a44eeebe/backe…" + }, + { + "author": "darfy22", + "created_at": "2026-03-11T17:00:58Z", + "updated_at": "2026-03-11T17:00:58Z", + "url": "https://github.com/666ghj/MiroFish/issues/148#issuecomment-4040697047", + "body_excerpt": "明白,期待进一步优化,谢谢。" + } + ], + "local_coverage": { + "number": 148, + "status": "covered", + "summary": "Interview env liveness now validates the persisted runner state and recorded process PID instead of trusting stale env_status.json alone, so Step 5 world-agent chat fails fast with the existing closed-environment guidance instead of hanging into a 504 when the simulation process has already exited.", + "local_refs": [ + "backend/app/services/simulation_runner.py", + "backend/tests/test_simulation_runner_actions.py", + "backend/tests/test_simulation_api_i18n.py" + ] + }, + "local_status": "covered", + "local_summary": "Interview env liveness now validates the persisted runner state and recorded process PID instead of trusting stale env_status.json alone, so Step 5 world-agent chat fails fast with the existing closed-environment guidance instead of hanging into a 504 when the simulation process has already exited.", + "triage_status": "covered", + "summary": "Interview env liveness now validates the persisted runner state and recorded process PID instead of trusting stale env_status.json alone, so Step 5 world-agent chat fails fast with the existing closed-environment guidance instead of hanging into a 504 when the simulation process has already exited.", + "coverage_status": "covered", + "coverage_summary": "Interview env liveness now validates the persisted runner state and recorded process PID instead of trusting stale env_status.json alone, so Step 5 world-agent chat fails fast with the existing closed-environment guidance instead of hanging into a 504 when the simulation process has already exited.", + "fork_issue_mirrored": true, + "fork_issue_number": 89, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/89" + }, + { + "number": 149, + "title": "一直卡在 Waiting for agent actions", + "url": "https://github.com/666ghj/MiroFish/issues/149", + "state": "open", + "created_at": "2026-03-11T16:56:33Z", + "updated_at": "2026-03-11T17:00:02Z", + "closed_at": null, + "labels": [ + "question" + ], + "author": "jidancong", + "body_excerpt": "<img width=\"947\" height=\"398\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/09b45da5-150c-4d3b-82c0-6ba2204c1743\" />", + "comment_count": 1, + "recent_comments": [ + { + "author": "dosubot[bot]", + "created_at": "2026-03-11T17:00:02Z", + "updated_at": "2026-03-11T17:00:02Z", + "url": "https://github.com/666ghj/MiroFish/issues/149#issuecomment-4040690744", + "body_excerpt": "<!-- Greeting --> Hi @jidancong! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the MiroFish team. <!-- Answer --> 这个问题通常是因为后端的 agent 动作数据没有正确生成或传递到前端。以下是几个常见原因和排查建议: **1. 检查 LLM API 配置** 最常见的原因是 [API URL 格式不正确](https://github.com…" + } + ], + "local_coverage": { + "number": 149, + "status": "covered", + "summary": "Step 3 now reconciles stale persisted `running` states when the worker PID is gone, and the detailed status payload exposes compact simulation-log diagnostics while waiting for the first actions. That prevents indefinite \"Waiting for agent actions\" polling after a dead worker and makes true startup stalls visible in the UI.", + "local_refs": [ + "backend/app/services/simulation_runner.py", + "backend/app/api/simulation.py", + "backend/tests/test_simulation_runner_actions.py", + "backend/tests/test_simulation_run_status_detail.py", + "frontend/src/components/Step3Simulation.vue" + ], + "validation": [ + "cd backend && uv run pytest -q tests/test_simulation_runner_actions.py tests/test_simulation_run_status_detail.py", + "npm --prefix frontend test", + "npm --prefix frontend run build" + ] + }, + "local_status": "covered", + "local_summary": "Step 3 now reconciles stale persisted `running` states when the worker PID is gone, and the detailed status payload exposes compact simulation-log diagnostics while waiting for the first actions. That prevents indefinite \"Waiting for agent actions\" polling after a dead worker and makes true startup stalls visible in the UI.", + "triage_status": "covered", + "summary": "Step 3 now reconciles stale persisted `running` states when the worker PID is gone, and the detailed status payload exposes compact simulation-log diagnostics while waiting for the first actions. That prevents indefinite \"Waiting for agent actions\" polling after a dead worker and makes true startup stalls visible in the UI.", + "coverage_status": "covered", + "coverage_summary": "Step 3 now reconciles stale persisted `running` states when the worker PID is gone, and the detailed status payload exposes compact simulation-log diagnostics while waiting for the first actions. That prevents indefinite \"Waiting for agent actions\" polling after a dead worker and makes true startup stalls visible in the UI.", + "fork_issue_mirrored": true, + "fork_issue_number": 90, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/90" + }, + { + "number": 146, + "title": "[Feature Request] Add Husky for Git Hook Automated Checks", + "url": "https://github.com/666ghj/MiroFish/issues/146", + "state": "open", + "created_at": "2026-03-11T15:12:43Z", + "updated_at": "2026-03-11T15:16:25Z", + "closed_at": null, + "labels": [ + "enhancement" + ], + "author": "fishwww-ww", + "body_excerpt": "Background The current project lacks automated validation before code commits, which may lead to the following issues: 1. Committing non-compliant code (e.g., syntax errors, messy formatting); 2. Inconsistent commit messages, which is not conducive to subsequent maintenance and version tracking; 3. Inefficiency in team collaboration due to the need for manual reminders of specifications. Solution…", + "comment_count": 0, + "recent_comments": [], + "local_coverage": { + "number": 146, + "status": "covered", + "summary": "The repo now ships an opt-in, repo-native git hook workflow: `.githooks/pre-commit` runs the shared fast validation bundle, `.githooks/pre-push` runs the full validation bundle, and `npm run hooks:install` enables them without introducing a mandatory Husky/Node-only hook dependency.", + "local_refs": [ + ".githooks/pre-commit", + ".githooks/pre-push", + "scripts/validate_repo.sh", + "scripts/install_git_hooks.sh", + "CONTRIBUTING.md", + "package.json", + "https://github.com/ivanzud/MiroFish/issues/88" + ], + "validation": [ + "bash ./scripts/validate_repo.sh --backend-only", + "bash ./scripts/validate_repo.sh --frontend-only", + "bash ./scripts/install_git_hooks.sh --help" + ], + "notes": "Mirrored into fork issue #88 on March 11, 2026. The workflow is intentionally opt-in and uses git core.hooksPath instead of requiring Husky." + }, + "local_status": "covered", + "local_summary": "The repo now ships an opt-in, repo-native git hook workflow: `.githooks/pre-commit` runs the shared fast validation bundle, `.githooks/pre-push` runs the full validation bundle, and `npm run hooks:install` enables them without introducing a mandatory Husky/Node-only hook dependency.", + "triage_status": "covered", + "summary": "The repo now ships an opt-in, repo-native git hook workflow: `.githooks/pre-commit` runs the shared fast validation bundle, `.githooks/pre-push` runs the full validation bundle, and `npm run hooks:install` enables them without introducing a mandatory Husky/Node-only hook dependency.", + "coverage_status": "covered", + "coverage_summary": "The repo now ships an opt-in, repo-native git hook workflow: `.githooks/pre-commit` runs the shared fast validation bundle, `.githooks/pre-push` runs the full validation bundle, and `npm run hooks:install` enables them without introducing a mandatory Husky/Node-only hook dependency.", + "fork_issue_mirrored": true, + "fork_issue_number": 88, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/88" + }, + { + "number": 145, + "title": "知识图谱中存在重复实体节点", + "url": "https://github.com/666ghj/MiroFish/issues/145", + "state": "open", + "created_at": "2026-03-11T14:43:11Z", + "updated_at": "2026-03-11T14:53:58Z", + "closed_at": null, + "labels": [], + "author": "Stayfoool", + "body_excerpt": "## 问题描述 在使用 MiroFish 构建知识图谱时,Zep 会将同一现实实体识别为多个不同节点。 例如输入包含\"特朗普\"相关内容的文本后,图谱中会同时出现\"特朗普\"和 \"美国总统特朗普\"两个独立节点,它们各自有独立的边和关系。 这会导致: - 图谱中同一实体的信息被分散到多个节点上 - 后续的模拟推演基于不完整的实体关系进行,影响准确性 - 图谱可视化时出现冗余节点,影响可读性 ## 复现步骤 1. 准备一段包含同一人物/组织不同称呼的背景文本 2. 通过前端正常流程构建知识图谱 3. 查看生成的图谱,可以看到同一实体被拆分为多个节点 ## 截图 <img width=\"675\" height=\"399\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/593f4188-e766-46b3-9b88-25486…", + "comment_count": 0, + "recent_comments": [], + "local_coverage": { + "number": 145, + "status": "partial", + "summary": "Repo-native partial mitigations are now landed locally across simulation inputs, backend graph/report/search/statistics/detail surfaces, raw graph introspection, node-edge introspection, textual tool output, both shipped graph renderers, and the visible frontend graph counters/logs: `ZepEntityReader.filter_defined_entities()` collapses obvious same-entity alias variants before simulation/profile generation, `ZepEntityReader.get_entity_with_context()` now merges alias-linked relations and related nodes for the entity-detail API, `backend/app/services/graph_builder.py` now collapses the same conservative alias pairs when serving `/api/graph/data/<graph_id>` and remaps duplicate edges to the retained node UUID, `backend/app/services/zep_tools.py` now collapses those aliases when building typed entity lists, raw node/edge introspection payloads, Panorama output, InsightForge entity/relationship summaries, QuickSearch/general search results, graph statistics, node-edge lookups, entity summaries including relations attached only to alias UUIDs, and `NodeInfo.to_text()` output, while preserving merged `alias_names` metadata so callers and downstream prompts can still see which labels were folded together. `frontend/src/views/processGraphData.js` and the shared `frontend/src/components/GraphPanel.vue` renderer now both collapse them while rendering graph data, the Process plus GraphPanel node detail drawers expose the folded non-canonical aliases via `frontend/src/components/graphAliasDetails.js`, and `frontend/src/components/graphPanelData.js` now drives deduplicated Step 1 / Process counters plus MainView refresh logs so title-prefixed duplicates such as `美国总统特朗普` vs `特朗普` no longer appear twice in the graph or its visible counts. Full graph-level persisted deduplication still remains tracked under beads issue `mirofish-975` because upstream PR #141 is not safe to cherry-pick wholesale.", + "local_refs": [ + ".beads/issues.jsonl", + "docs/upstream-triage.md", + "origin/mirror/upstream-pr-141", + "backend/app/services/zep_entity_reader.py", + "backend/tests/test_zep_entity_reader.py", + "backend/app/services/graph_builder.py", + "backend/tests/test_graph_builder.py", + "backend/app/services/zep_tools.py", + "backend/tests/test_zep_tools_dedup.py", + "backend/tests/test_zep_tools_i18n.py", + "frontend/src/components/GraphPanel.vue", + "frontend/src/components/graphAliasDetails.js", + "frontend/src/components/graphPanelData.js", + "frontend/src/components/Step1GraphBuild.vue", + "frontend/tests/graphAliasDetails.test.mjs", + "frontend/tests/graphPanelData.test.mjs", + "frontend/src/views/MainView.vue", + "frontend/src/views/Process.vue", + "frontend/src/views/processGraphData.js", + "frontend/tests/processGraphData.test.mjs" + ], + "validation": [ + "cd backend && uv run pytest -q tests/test_graph_builder.py tests/test_zep_entity_reader.py tests/test_zep_tools_dedup.py tests/test_zep_tools_i18n.py", + "python3 -m compileall backend/app/services/zep_entity_reader.py backend/app/services/graph_builder.py backend/app/services/zep_tools.py", + "bash ./scripts/test_backend_lite.sh", + "frontend: npm --prefix frontend test", + "frontend: npm --prefix frontend run build", + "triage diff review" + ], + "notes": "Mirrored into fork issue tracking on March 11, 2026 after enabling issues on ivanzud/MiroFish. 2026-03-11: Added a third repo-native partial mitigation in backend report/search tooling so conservative alias pairs are collapsed before Step 4/analysis surfaces render entity summaries and relationship chains. 2026-03-11: Added a fourth repo-native partial mitigation in backend graph-data responses so `/api/graph/data/<graph_id>` collapses the same obvious alias pairs and remaps duplicate edges without mutating stored Zep nodes. 2026-03-11: Added a fifth repo-native partial mitigation in QuickSearch/general search results so `search_graph()` and the local-search fallback collapse the same obvious alias pairs, deduplicate summary facts, and remap duplicate edges before report tooling or API callers consume the result payload. 2026-03-11: Added a seventh repo-native partial mitigation in backend graph statistics and entity-summary helpers so alias duplicates no longer inflate stats counts and alias queries resolve to the canonical merged entity payload. 2026-03-11: Added an eighth repo-native partial mitigation in backend entity summaries so alias-linked relations are collected from the full edge set before canonical remapping, which prevents summaries from dropping edges attached only to an alias UUID. 2026-03-11: Added a ninth repo-native partial mitigation in backend entity-detail reads so `get_entity_with_context()` resolves the requested node against its alias group, includes alias-linked relations, and deduplicates related-node payloads. 2026-03-11: Added a tenth repo-native partial mitigation in zep_tools node-edge lookups so `get_node_edges()` now resolves the requested node against its alias group and remaps duplicate edges instead of dropping relationships attached only to an alias UUID. 2026-03-11: Added an eleventh repo-native partial mitigation in raw zep_tools graph introspection so `get_all_nodes()` and `get_all_edges()` now collapse obvious alias duplicates and remap duplicate edge payloads before report-side callers consume the graph snapshot. 2026-03-11: Added a fourteenth repo-native partial mitigation in textual zep_tools node rendering so `NodeInfo.to_text()` now includes merged alias names with locale-aware labels, preserving the folded names in downstream report/runtime prompts. 2026-03-11: Added a fifteenth repo-native partial mitigation in the shared frontend graph renderer so `frontend/src/components/GraphPanel.vue` now reuses the conservative display-only alias-collapse mapper and no longer shows obvious duplicate entities outside the Process view. 2026-03-11: Added a sixteenth repo-native partial mitigation in the Process and shared GraphPanel node detail drawers so `frontend/src/components/graphAliasDetails.js` filters merged `alias_names` down to the folded non-canonical labels and both detail panels render them explicitly instead of hiding which source names collapsed into the canonical node. 2026-03-11: Added a seventeenth repo-native partial mitigation in frontend graph stats so `frontend/src/components/graphPanelData.js` now drives Step 1 / Process counters and MainView refresh logs through the same alias-collapse mapping as the visible graph renderer." + }, + "local_status": "partial", + "local_summary": "Repo-native partial mitigations are now landed locally across simulation inputs, backend graph/report/search/statistics/detail surfaces, raw graph introspection, node-edge introspection, textual tool output, both shipped graph renderers, and the visible frontend graph counters/logs: `ZepEntityReader.filter_defined_entities()` collapses obvious same-entity alias variants before simulation/profile generation, `ZepEntityReader.get_entity_with_context()` now merges alias-linked relations and related nodes for the entity-detail API, `backend/app/services/graph_builder.py` now collapses the same conservative alias pairs when serving `/api/graph/data/<graph_id>` and remaps duplicate edges to the retained node UUID, `backend/app/services/zep_tools.py` now collapses those aliases when building typed entity lists, raw node/edge introspection payloads, Panorama output, InsightForge entity/relationship summaries, QuickSearch/general search results, graph statistics, node-edge lookups, entity summaries including relations attached only to alias UUIDs, and `NodeInfo.to_text()` output, while preserving merged `alias_names` metadata so callers and downstream prompts can still see which labels were folded together. `frontend/src/views/processGraphData.js` and the shared `frontend/src/components/GraphPanel.vue` renderer now both collapse them while rendering graph data, the Process plus GraphPanel node detail drawers expose the folded non-canonical aliases via `frontend/src/components/graphAliasDetails.js`, and `frontend/src/components/graphPanelData.js` now drives deduplicated Step 1 / Process counters plus MainView refresh logs so title-prefixed duplicates such as `美国总统特朗普` vs `特朗普` no longer appear twice in the graph or its visible counts. Full graph-level persisted deduplication still remains tracked under beads issue `mirofish-975` because upstream PR #141 is not safe to cherry-pick wholesale.", + "triage_status": "partial", + "summary": "Repo-native partial mitigations are now landed locally across simulation inputs, backend graph/report/search/statistics/detail surfaces, raw graph introspection, node-edge introspection, textual tool output, both shipped graph renderers, and the visible frontend graph counters/logs: `ZepEntityReader.filter_defined_entities()` collapses obvious same-entity alias variants before simulation/profile generation, `ZepEntityReader.get_entity_with_context()` now merges alias-linked relations and related nodes for the entity-detail API, `backend/app/services/graph_builder.py` now collapses the same conservative alias pairs when serving `/api/graph/data/<graph_id>` and remaps duplicate edges to the retained node UUID, `backend/app/services/zep_tools.py` now collapses those aliases when building typed entity lists, raw node/edge introspection payloads, Panorama output, InsightForge entity/relationship summaries, QuickSearch/general search results, graph statistics, node-edge lookups, entity summaries including relations attached only to alias UUIDs, and `NodeInfo.to_text()` output, while preserving merged `alias_names` metadata so callers and downstream prompts can still see which labels were folded together. `frontend/src/views/processGraphData.js` and the shared `frontend/src/components/GraphPanel.vue` renderer now both collapse them while rendering graph data, the Process plus GraphPanel node detail drawers expose the folded non-canonical aliases via `frontend/src/components/graphAliasDetails.js`, and `frontend/src/components/graphPanelData.js` now drives deduplicated Step 1 / Process counters plus MainView refresh logs so title-prefixed duplicates such as `美国总统特朗普` vs `特朗普` no longer appear twice in the graph or its visible counts. Full graph-level persisted deduplication still remains tracked under beads issue `mirofish-975` because upstream PR #141 is not safe to cherry-pick wholesale.", + "coverage_status": "partial", + "coverage_summary": "Repo-native partial mitigations are now landed locally across simulation inputs, backend graph/report/search/statistics/detail surfaces, raw graph introspection, node-edge introspection, textual tool output, both shipped graph renderers, and the visible frontend graph counters/logs: `ZepEntityReader.filter_defined_entities()` collapses obvious same-entity alias variants before simulation/profile generation, `ZepEntityReader.get_entity_with_context()` now merges alias-linked relations and related nodes for the entity-detail API, `backend/app/services/graph_builder.py` now collapses the same conservative alias pairs when serving `/api/graph/data/<graph_id>` and remaps duplicate edges to the retained node UUID, `backend/app/services/zep_tools.py` now collapses those aliases when building typed entity lists, raw node/edge introspection payloads, Panorama output, InsightForge entity/relationship summaries, QuickSearch/general search results, graph statistics, node-edge lookups, entity summaries including relations attached only to alias UUIDs, and `NodeInfo.to_text()` output, while preserving merged `alias_names` metadata so callers and downstream prompts can still see which labels were folded together. `frontend/src/views/processGraphData.js` and the shared `frontend/src/components/GraphPanel.vue` renderer now both collapse them while rendering graph data, the Process plus GraphPanel node detail drawers expose the folded non-canonical aliases via `frontend/src/components/graphAliasDetails.js`, and `frontend/src/components/graphPanelData.js` now drives deduplicated Step 1 / Process counters plus MainView refresh logs so title-prefixed duplicates such as `美国总统特朗普` vs `特朗普` no longer appear twice in the graph or its visible counts. Full graph-level persisted deduplication still remains tracked under beads issue `mirofish-975` because upstream PR #141 is not safe to cherry-pick wholesale.", + "fork_issue_mirrored": true, + "fork_issue_number": 2, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/2" + }, + { + "number": 142, + "title": "这个方向最后商业化落地应用的点是什么呢", + "url": "https://github.com/666ghj/MiroFish/issues/142", + "state": "open", + "created_at": "2026-03-11T14:01:19Z", + "updated_at": "2026-03-11T14:03:26Z", + "closed_at": null, + "labels": [ + "question" + ], + "author": "jack1234-byte", + "body_excerpt": "", + "comment_count": 0, + "recent_comments": [], + "local_coverage": { + "number": 142, + "status": "no_action", + "summary": "Upstream issue #142 asks about long-term commercialization direction rather than reporting a reproducible bug or scoped feature request, so there is no local implementation task attached to it.", + "local_refs": [ + "docs/upstream-open-state.json" + ], + "validation": [ + "triage only" + ] + }, + "local_status": "no_action", + "local_summary": "Upstream issue #142 asks about long-term commercialization direction rather than reporting a reproducible bug or scoped feature request, so there is no local implementation task attached to it.", + "triage_status": "no_action", + "summary": "Upstream issue #142 asks about long-term commercialization direction rather than reporting a reproducible bug or scoped feature request, so there is no local implementation task attached to it.", + "coverage_status": "no_action", + "coverage_summary": "Upstream issue #142 asks about long-term commercialization direction rather than reporting a reproducible bug or scoped feature request, so there is no local implementation task attached to it.", + "fork_issue_mirrored": true, + "fork_issue_number": 3, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/3" + }, + { + "number": 64, + "title": "一直卡在上传文件错误:Request failed with status code 500", + "url": "https://github.com/666ghj/MiroFish/issues/64", + "state": "open", + "created_at": "2026-01-31T13:10:06Z", + "updated_at": "2026-03-11T13:50:29Z", + "closed_at": null, + "labels": [], + "author": "G-LJDS2022", + "body_excerpt": "<img width=\"1206\" height=\"1234\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/5befa186-6f0f-493a-a6fa-7fb33940f233\" /> TXT、MD、PDF文件格式都试了,内容甚至精简到就几百字,但就是卡在上传文件错误,到底什么原因?", + "comment_count": 15, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-02-02T07:16:30Z", + "updated_at": "2026-02-02T07:16:30Z", + "url": "https://github.com/666ghj/MiroFish/issues/64#issuecomment-3833393441", + "body_excerpt": "以前的代码因为编码格式的缘故会报这样的错,最新代码已经修复了。 你是把他部署在服务器上吗,那好像会有一些问题。" + }, + { + "author": "wjh-w", + "created_at": "2026-02-05T07:52:32Z", + "updated_at": "2026-02-05T07:52:32Z", + "url": "https://github.com/666ghj/MiroFish/issues/64#issuecomment-3851616788", + "body_excerpt": "我也遇到这样的问题上传文件 的时候一直处于 500的问题 <img width=\"1051\" height=\"356\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/536df0ba-f13a-4dcf-a588-455f9c4f1084\" />" + }, + { + "author": "HAN-GITWarehouse", + "created_at": "2026-02-07T03:21:04Z", + "updated_at": "2026-02-07T03:21:04Z", + "url": "https://github.com/666ghj/MiroFish/issues/64#issuecomment-3863455964", + "body_excerpt": "这个 500的报错 你要看下你前端界面 调用generate 这个接口的反馈 结果反馈是什么吧 我之前也是500的 报错 原因在调用这个接口的时候 发现 因为zep cloude 只提供 秘钥 但是不提供模型 而 项目中的模型 你要需要去申请一个没药来使用 很多模型也并不是免费的 但是一般 新用户注册 都有免费额度" + } + ], + "local_coverage": { + "number": 64, + "status": "covered", + "summary": "Upload and graph-build failures now surface structured per-file parser/config validation errors instead of collapsing common deployment or document-ingest problems into a generic 500.", + "local_refs": [ + "backend/app/api/graph.py", + "backend/app/config.py", + "frontend/src/views/Process.vue", + "backend/tests/test_graph_upload_api.py" + ], + "validation": [ + "scripts/test_backend_lite.sh", + "frontend: npm test", + "frontend: npm run build" + ] + }, + "local_status": "covered", + "local_summary": "Upload and graph-build failures now surface structured per-file parser/config validation errors instead of collapsing common deployment or document-ingest problems into a generic 500.", + "triage_status": "covered", + "summary": "Upload and graph-build failures now surface structured per-file parser/config validation errors instead of collapsing common deployment or document-ingest problems into a generic 500.", + "coverage_status": "covered", + "coverage_summary": "Upload and graph-build failures now surface structured per-file parser/config validation errors instead of collapsing common deployment or document-ingest problems into a generic 500.", + "fork_issue_mirrored": true, + "fork_issue_number": 4, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/4" + }, + { + "number": 139, + "title": "Graph build task failed :(", + "url": "https://github.com/666ghj/MiroFish/issues/139", + "state": "closed", + "created_at": "2026-03-11T11:11:20Z", + "updated_at": "2026-03-11T12:29:18Z", + "closed_at": "2026-03-11T12:29:18Z", + "labels": [], + "author": "darfy22", + "body_excerpt": "Graph build task failed: Traceback (most recent call last): File \"/app/backend/.venv/lib/python3.11/site-packages/zep_cloud/graph/raw_client.py\", line 713, in create _response_json = _response.json() ^^^^^^^^^^^^^^^^ File \"/app/backend/.venv/lib/python3.11/site-packages/httpx/_models.py\", line 832, in json return jsonlib.loads(self.content, **kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File \"/u…", + "comment_count": 2, + "recent_comments": [ + { + "author": "dosubot[bot]", + "created_at": "2026-03-11T11:13:35Z", + "updated_at": "2026-03-11T11:13:35Z", + "url": "https://github.com/666ghj/MiroFish/issues/139#issuecomment-4038479566", + "body_excerpt": "<!-- Answer --> The `401 unauthorized` error indicates a Zep Cloud API authentication issue. Here are the most likely causes and solutions: **1. Version Bug** — If you're using MiroFish v0.1.0, there's a [known authentication bug](https://…" + }, + { + "author": "darfy22", + "created_at": "2026-03-11T12:29:18Z", + "updated_at": "2026-03-11T12:29:18Z", + "url": "https://github.com/666ghj/MiroFish/issues/139#issuecomment-4038887563", + "body_excerpt": "done" + } + ], + "local_coverage": { + "number": 139, + "status": "covered", + "summary": "Graph-build task failures now classify Zep 401/unauthorized responses into a concise ZEP_API_KEY guidance message and strip embedded traceback noise before returning task payload errors.", + "local_refs": [ + "backend/app/services/graph_builder.py", + "backend/tests/test_graph_builder.py", + "backend/tests/test_graph_upload_api.py" + ], + "validation": [ + "backend/tests/test_graph_builder.py::test_format_user_facing_error_maps_embedded_traceback_auth_failures", + "backend/tests/test_graph_upload_api.py::test_build_graph_task_persists_sanitized_zep_auth_error" + ], + "notes": "Mirrored into fork issue tracking on March 11, 2026 after enabling issues on ivanzud/MiroFish." + }, + "local_status": "covered", + "local_summary": "Graph-build task failures now classify Zep 401/unauthorized responses into a concise ZEP_API_KEY guidance message and strip embedded traceback noise before returning task payload errors.", + "triage_status": "covered", + "summary": "Graph-build task failures now classify Zep 401/unauthorized responses into a concise ZEP_API_KEY guidance message and strip embedded traceback noise before returning task payload errors.", + "coverage_status": "covered", + "coverage_summary": "Graph-build task failures now classify Zep 401/unauthorized responses into a concise ZEP_API_KEY guidance message and strip embedded traceback noise before returning task payload errors.", + "fork_issue_mirrored": true, + "fork_issue_number": 39, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/39" + }, + { + "number": 140, + "title": "让我想到了刘慈欣的一个小说,要是把真实世界放进去,得多大的算力啊", + "url": "https://github.com/666ghj/MiroFish/issues/140", + "state": "open", + "created_at": "2026-03-11T11:20:00Z", + "updated_at": "2026-03-11T11:20:00Z", + "closed_at": null, + "labels": [], + "author": "p20061", + "body_excerpt": "", + "comment_count": 0, + "recent_comments": [], + "local_coverage": { + "number": 140, + "status": "no_action", + "summary": "Upstream issue #140 is general project commentary rather than an actionable defect report or scoped feature request, so it does not require local implementation work.", + "local_refs": [ + "docs/upstream-open-state.json" + ], + "validation": [ + "triage only" + ] + }, + "local_status": "no_action", + "local_summary": "Upstream issue #140 is general project commentary rather than an actionable defect report or scoped feature request, so it does not require local implementation work.", + "triage_status": "no_action", + "summary": "Upstream issue #140 is general project commentary rather than an actionable defect report or scoped feature request, so it does not require local implementation work.", + "coverage_status": "no_action", + "coverage_summary": "Upstream issue #140 is general project commentary rather than an actionable defect report or scoped feature request, so it does not require local implementation work.", + "fork_issue_mirrored": true, + "fork_issue_number": 5, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/5" + }, + { + "number": 138, + "title": "生成的报告让人看不懂", + "url": "https://github.com/666ghj/MiroFish/issues/138", + "state": "closed", + "created_at": "2026-03-11T09:53:19Z", + "updated_at": "2026-03-11T10:55:48Z", + "closed_at": "2026-03-11T10:55:48Z", + "labels": [ + "question" + ], + "author": "huamingjie0815", + "body_excerpt": "生成的报告感觉有点太偏题了,看不懂😭 ,比如我写了一个游戏的设计方案,预测这个游戏的受众群体将会是什么,给我的报告题目是《未来受众群体生态的静默与解体:一项基于模拟的预测报告》", + "comment_count": 1, + "recent_comments": [ + { + "author": "dosubot[bot]", + "created_at": "2026-03-11T09:56:26Z", + "updated_at": "2026-03-11T09:56:26Z", + "url": "https://github.com/666ghj/MiroFish/issues/138#issuecomment-4037933291", + "body_excerpt": "<!-- Greeting --> Hi @huamingjie0815! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the MiroFish team. <!-- Answer --> 感谢反馈!这个问题我找到原因了 😊 **根本原因**:报告标题生成的 [Prompt 模板](https://github.com/666ghj/MiroFish/blob/985f89f49acbb44ee14d9d6…" + } + ], + "triage_status": "untracked", + "summary": "生成的报告感觉有点太偏题了,看不懂😭 ,比如我写了一个游戏的设计方案,预测这个游戏的受众群体将会是什么,给我的报告题目是《未来受众群体生态的静默与解体:一项基于模拟的预测报告》", + "coverage_status": "untracked", + "coverage_summary": "生成的报告感觉有点太偏题了,看不懂😭 ,比如我写了一个游戏的设计方案,预测这个游戏的受众群体将会是什么,给我的报告题目是《未来受众群体生态的静默与解体:一项基于模拟的预测报告》", + "fork_issue_mirrored": true, + "fork_issue_number": 40, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/40" + }, + { + "number": 135, + "title": "报错,Zep图谱构建失败", + "url": "https://github.com/666ghj/MiroFish/issues/135", + "state": "open", + "created_at": "2026-03-11T08:51:14Z", + "updated_at": "2026-03-11T08:55:24Z", + "closed_at": null, + "labels": [], + "author": "rheeh", + "body_excerpt": "Graph build task failed: Traceback (most recent call last): File \"/app/backend/app/api/graph.py\", line 418, in build_task builder.set_ontology(graph_id, ontology) File \"/app/backend/app/services/graph_builder.py\", line 230, in set_ontology attr_name = safe_attr_name(attr_def[\"name\"]) # 使用安全名称 ~~~~~~~~^^^^^^^^ TypeError: string indices must be integers, not 'str'", + "comment_count": 1, + "recent_comments": [ + { + "author": "dosubot[bot]", + "created_at": "2026-03-11T08:55:24Z", + "updated_at": "2026-03-11T08:55:24Z", + "url": "https://github.com/666ghj/MiroFish/issues/135#issuecomment-4037553350", + "body_excerpt": "<!-- Greeting --> Hi @rheeh! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the MiroFish team. <!-- Answer --> 这个错误是因为 `attr_def` 应该是字典格式 `{\"name\": \"attr_name\", ...}`,但实际收到的是字符串。 **根本原因**:LLM 生成的 ontology 中,`attributes` 字段可能返回了简单的…" + } + ], + "local_coverage": { + "number": 135, + "status": "covered", + "summary": "Graph ontology ingestion now accepts string-valued attribute definitions for entity and edge schemas, so malformed LLM ontology output no longer crashes graph builds with `TypeError: string indices must be integers`.", + "local_refs": [ + "backend/app/services/graph_builder.py", + "backend/tests/test_graph_builder.py" + ], + "validation": [ + "backend/tests/test_graph_builder.py::test_set_ontology_accepts_string_attribute_definitions" + ], + "notes": "Mirrored into fork issue tracking on March 11, 2026 after enabling issues on ivanzud/MiroFish." + }, + "local_status": "covered", + "local_summary": "Graph ontology ingestion now accepts string-valued attribute definitions for entity and edge schemas, so malformed LLM ontology output no longer crashes graph builds with `TypeError: string indices must be integers`.", + "triage_status": "covered", + "summary": "Graph ontology ingestion now accepts string-valued attribute definitions for entity and edge schemas, so malformed LLM ontology output no longer crashes graph builds with `TypeError: string indices must be integers`.", + "coverage_status": "covered", + "coverage_summary": "Graph ontology ingestion now accepts string-valued attribute definitions for entity and edge schemas, so malformed LLM ontology output no longer crashes graph builds with `TypeError: string indices must be integers`.", + "fork_issue_mirrored": true, + "fork_issue_number": 6, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/6" + }, + { + "number": 133, + "title": "本地部署,启动已经显示成功了,访问却失败", + "url": "https://github.com/666ghj/MiroFish/issues/133", + "state": "open", + "created_at": "2026-03-11T07:45:16Z", + "updated_at": "2026-03-11T07:47:36Z", + "closed_at": null, + "labels": [ + "question" + ], + "author": "Axing93", + "body_excerpt": "前后端启动都显示成功了,0.0.0.0:5001无法访问,提示404。前端0.0.0.0:3000虽然可以访问,但是一到这个界面就会报错 <img width=\"2550\" height=\"1233\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/032cf0ac-78ae-406d-831d-da5b9a28d5a0\" />,看了项目也没有报错日志,请问这是什么问题呢", + "comment_count": 1, + "recent_comments": [ + { + "author": "dosubot[bot]", + "created_at": "2026-03-11T07:47:36Z", + "updated_at": "2026-03-11T07:47:36Z", + "url": "https://github.com/666ghj/MiroFish/issues/133#issuecomment-4037175500", + "body_excerpt": "<!-- Greeting --> Hi @Axing93! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the MiroFish team. <!-- Answer --> 这是一个常见的前后端连接配置问题。 **后端 5001 返回 404 是正常的** — [根路径 `/` 未定义端点](https://github.com/666ghj/MiroFish/issues/68),实际 API 通过具体…" + } + ], + "local_coverage": { + "number": 133, + "status": "covered", + "summary": "The backend root path `/` plus `/health` and `/healthz` now return a small JSON status payload with the live API prefixes, so local or Docker users no longer need to infer backend health from a bare 404.", + "local_refs": [ + "backend/app/__init__.py", + "backend/tests/test_app_routes.py" + ], + "validation": [ + "backend/tests/test_app_routes.py::test_root_and_health_endpoints_expose_backend_status" + ], + "notes": "Mirrored into fork issue tracking on March 11, 2026 after enabling issues on ivanzud/MiroFish." + }, + "local_status": "covered", + "local_summary": "The backend root path `/` plus `/health` and `/healthz` now return a small JSON status payload with the live API prefixes, so local or Docker users no longer need to infer backend health from a bare 404.", + "triage_status": "covered", + "summary": "The backend root path `/` plus `/health` and `/healthz` now return a small JSON status payload with the live API prefixes, so local or Docker users no longer need to infer backend health from a bare 404.", + "coverage_status": "covered", + "coverage_summary": "The backend root path `/` plus `/health` and `/healthz` now return a small JSON status payload with the live API prefixes, so local or Docker users no longer need to infer backend health from a bare 404.", + "fork_issue_mirrored": true, + "fork_issue_number": 7, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/7" + }, + { + "number": 121, + "title": "卡在了Exception in handleNewProject: Network Error", + "url": "https://github.com/666ghj/MiroFish/issues/121", + "state": "closed", + "created_at": "2026-03-10T15:46:02Z", + "updated_at": "2026-03-11T02:15:10Z", + "closed_at": "2026-03-11T02:15:10Z", + "labels": [ + "question" + ], + "author": "jackytianjp", + "body_excerpt": "<img width=\"859\" height=\"151\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/1a41f8a5-2cff-40a3-837b-f4955cc7b9b7\" />", + "comment_count": 3, + "recent_comments": [ + { + "author": "dosubot[bot]", + "created_at": "2026-03-10T15:49:04Z", + "updated_at": "2026-03-10T15:49:04Z", + "url": "https://github.com/666ghj/MiroFish/issues/121#issuecomment-4032505895", + "body_excerpt": "<!-- Greeting --> Hi @jackytianjp! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the MiroFish team. <!-- Answer --> 这个 \"Network Error\" 发生在 [`handleNewProject`](https://github.com/666ghj/MiroFish/blob/985f89f49acbb44ee14d9d680682c…" + }, + { + "author": "darfy22", + "created_at": "2026-03-10T17:17:48Z", + "updated_at": "2026-03-10T17:23:27Z", + "url": "https://github.com/666ghj/MiroFish/issues/121#issuecomment-4033122816", + "body_excerpt": "我也是遇到同样的问题,会不会是因为部署在云端服务器的原因,后端服务配置地址需要在哪里改为服务器IP地址? @dosu" + }, + { + "author": "jackytianjp", + "created_at": "2026-03-11T02:15:02Z", + "updated_at": "2026-03-11T02:15:02Z", + "url": "https://github.com/666ghj/MiroFish/issues/121#issuecomment-4035711587", + "body_excerpt": "## ✅ 已解决 感谢Dosu的帮助和启发 ### 问题描述 使用 Docker 部署时,前端调用 `/api/graph/ontology/generate` 接口报 `Network Error`,后端日志中完全没有收到任何请求。 ### 根本原因 前端代码默认将后端地址写死为 `http://localhost:5001`。在 Docker 容器内部这没有问题,但当你通过 NAS 或服务器的 IP 地址(如 `http://192.168.x.x:3000`)访问前端…" + } + ], + "local_coverage": { + "number": 121, + "status": "covered", + "summary": "New-project startup errors now resolve raw frontend `Network Error` failures into an actionable backend URL and proxy/CORS hint instead of leaving users at a generic `handleNewProject` failure banner.", + "local_refs": [ + "frontend/src/api/errors.js", + "frontend/tests/errors.test.mjs", + "https://github.com/ivanzud/MiroFish/issues/41" + ], + "validation": [ + "npm --prefix frontend test -- errors.test.mjs", + "npm --prefix frontend run build" + ], + "notes": "Mirrored into fork issue #41 on March 11, 2026. This backfills the landed frontend error-diagnostics work already represented by upstream PR #125." + }, + "local_status": "covered", + "local_summary": "New-project startup errors now resolve raw frontend `Network Error` failures into an actionable backend URL and proxy/CORS hint instead of leaving users at a generic `handleNewProject` failure banner.", + "triage_status": "covered", + "summary": "New-project startup errors now resolve raw frontend `Network Error` failures into an actionable backend URL and proxy/CORS hint instead of leaving users at a generic `handleNewProject` failure banner.", + "coverage_status": "covered", + "coverage_summary": "New-project startup errors now resolve raw frontend `Network Error` failures into an actionable backend URL and proxy/CORS hint instead of leaving users at a generic `handleNewProject` failure banner.", + "fork_issue_mirrored": true, + "fork_issue_number": 41, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/41" + }, + { + "number": 123, + "title": "プリセット業界知識RAGの導入(方式3: ローカルファイル注入)", + "url": "https://github.com/666ghj/MiroFish/issues/123", + "state": "closed", + "created_at": "2026-03-10T21:48:06Z", + "updated_at": "2026-03-10T21:48:44Z", + "closed_at": "2026-03-10T21:48:44Z", + "labels": [], + "author": "minicoohei", + "body_excerpt": "## 概要 シミュレーションのエージェント生成時に、キュレーション済みの業界知識・ドメイン知識をLLMプロンプトに注入する仕組みを構築する。 ## 背景 - 現状、Topプレイヤー生成・転職先企業推定・キャリア評価はすべてLLMの内部知識のみに依存 - Web検索APIの導入はプロンプトインジェクションリスクがある - Zepグラフに業界知識を入れるとグラフ可視化が汚れる ## 方針(方式3: ローカルファイル注入) - キュレーション済みMarkdownファイルとして業界知識を保持 - 候補者の職種・業界をLLM判定した時点で、該当ファイルのみを読み込みプロンプトに注入 - Zepグラフは候補者データ専用のまま維持 ## ファイル構成案 ``` preset_knowledge/ ├── industries/ │ ├── it_software.md │ ├── consulting…", + "comment_count": 0, + "recent_comments": [], + "triage_status": "untracked", + "summary": "## 概要 シミュレーションのエージェント生成時に、キュレーション済みの業界知識・ドメイン知識をLLMプロンプトに注入する仕組みを構築する。 ## 背景 - 現状、Topプレイヤー生成・転職先企業推定・キャリア評価はすべてLLMの内部知識のみに依存 - Web検索APIの導入はプロンプトインジェクションリスクがある - Zepグラフに業界知識を入れるとグラフ可視化が汚れる ## 方針(方式3: ローカルファイル注入) - キュレーション済みMarkdownファイルとして業界知識を保持 - 候補者の職種・業界をLLM判定した時点で、該当ファイルのみを読み込みプロンプトに注入 - Zepグラフは候補者データ専用のまま維持 ## ファイル構成案 ``` preset_knowledge/ ├── industries/ │ ├── it_software.md │ ├── consulting…", + "coverage_status": "untracked", + "coverage_summary": "## 概要 シミュレーションのエージェント生成時に、キュレーション済みの業界知識・ドメイン知識をLLMプロンプトに注入する仕組みを構築する。 ## 背景 - 現状、Topプレイヤー生成・転職先企業推定・キャリア評価はすべてLLMの内部知識のみに依存 - Web検索APIの導入はプロンプトインジェクションリスクがある - Zepグラフに業界知識を入れるとグラフ可視化が汚れる ## 方針(方式3: ローカルファイル注入) - キュレーション済みMarkdownファイルとして業界知識を保持 - 候補者の職種・業界をLLM判定した時点で、該当ファイルのみを読み込みプロンプトに注入 - Zepグラフは候補者データ専用のまま維持 ## ファイル構成案 ``` preset_knowledge/ ├── industries/ │ ├── it_software.md │ ├── consulting…", + "fork_issue_mirrored": true, + "fork_issue_number": 42, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/42" + }, + { + "number": 117, + "title": "### Feature Request: English Language Support", + "url": "https://github.com/666ghj/MiroFish/issues/117", + "state": "open", + "created_at": "2026-03-10T08:44:18Z", + "updated_at": "2026-03-10T08:46:24Z", + "closed_at": null, + "labels": [ + "enhancement" + ], + "author": "malpaniga", + "body_excerpt": "Hi, First of all, thank you for creating and open-sourcing this amazing project. MiroFish is a very interesting and powerful multi-agent prediction engine. Currently, a large portion of the documentation, UI text, and comments appear to be primarily in Chinese. This makes it difficult for international developers to fully understand and use the project. ### Request It would be very helpful if the…", + "comment_count": 0, + "recent_comments": [], + "local_coverage": { + "number": 117, + "status": "covered", + "summary": "The English support sweep now covers workflow chrome, deterministic Step 2/3 system-log copy, graph-build worker progress, report/interview parsing, Step 5 interview fallbacks, and simulation-config placeholder labels: Step 3/5 labels flow through shared i18n dictionaries, Step 2 prepare-stage progress and Step 3 round/PID logs localize through shared helpers, GraphBuilderService now persists English task-status strings for its initial worker milestones, Step 4 tool-output parsers accept both Chinese and English markers, zep_tools localizes deterministic interview-selection/question/summary fallback copy in English mode, and SimulationConfigGenerator now routes unknown entity/poster fallback labels through the active locale instead of leaking hardcoded English `Unknown` into zh-mode config output.", + "local_refs": [ + "frontend/src/components/Step2EnvSetup.vue", + "frontend/src/components/Step3Simulation.vue", + "backend/app/services/graph_builder.py", + "frontend/src/components/Step4Report.vue", + "frontend/src/components/Step5Interaction.vue", + "frontend/src/components/reportParsers.js", + "frontend/src/components/simulationLogMessages.js", + "frontend/src/components/step5Profiles.js", + "frontend/src/components/simulationTimeline.js", + "backend/app/services/zep_tools.py", + "backend/app/services/simulation_config_generator.py", + "frontend/src/i18n/locales/en.js", + "frontend/src/i18n/locales/zh.js", + "backend/tests/test_graph_builder.py", + "backend/tests/test_openai_compat_services.py", + "frontend/tests/simulationLogMessages.test.mjs", + "backend/tests/test_zep_tools_i18n.py" + ], + "validation": [ + "backend/tests/test_graph_builder.py", + "backend/tests/test_openai_compat_services.py", + "frontend/tests/reportParsers.test.mjs", + "frontend/tests/simulationLogMessages.test.mjs", + "frontend/tests/simulationTimeline.test.mjs", + "frontend/tests/step5Profiles.test.mjs", + "backend/tests/test_zep_tools_i18n.py", + "frontend: npm test", + "frontend: npm run build", + "scripts/test_backend_lite.sh" + ], + "notes": "Mirrored into fork issue tracking on March 11, 2026 after enabling issues on ivanzud/MiroFish." + }, + "local_status": "covered", + "local_summary": "The English support sweep now covers workflow chrome, deterministic Step 2/3 system-log copy, graph-build worker progress, report/interview parsing, Step 5 interview fallbacks, and simulation-config placeholder labels: Step 3/5 labels flow through shared i18n dictionaries, Step 2 prepare-stage progress and Step 3 round/PID logs localize through shared helpers, GraphBuilderService now persists English task-status strings for its initial worker milestones, Step 4 tool-output parsers accept both Chinese and English markers, zep_tools localizes deterministic interview-selection/question/summary fallback copy in English mode, and SimulationConfigGenerator now routes unknown entity/poster fallback labels through the active locale instead of leaking hardcoded English `Unknown` into zh-mode config output.", + "triage_status": "covered", + "summary": "The English support sweep now covers workflow chrome, deterministic Step 2/3 system-log copy, graph-build worker progress, report/interview parsing, Step 5 interview fallbacks, and simulation-config placeholder labels: Step 3/5 labels flow through shared i18n dictionaries, Step 2 prepare-stage progress and Step 3 round/PID logs localize through shared helpers, GraphBuilderService now persists English task-status strings for its initial worker milestones, Step 4 tool-output parsers accept both Chinese and English markers, zep_tools localizes deterministic interview-selection/question/summary fallback copy in English mode, and SimulationConfigGenerator now routes unknown entity/poster fallback labels through the active locale instead of leaking hardcoded English `Unknown` into zh-mode config output.", + "coverage_status": "covered", + "coverage_summary": "The English support sweep now covers workflow chrome, deterministic Step 2/3 system-log copy, graph-build worker progress, report/interview parsing, Step 5 interview fallbacks, and simulation-config placeholder labels: Step 3/5 labels flow through shared i18n dictionaries, Step 2 prepare-stage progress and Step 3 round/PID logs localize through shared helpers, GraphBuilderService now persists English task-status strings for its initial worker milestones, Step 4 tool-output parsers accept both Chinese and English markers, zep_tools localizes deterministic interview-selection/question/summary fallback copy in English mode, and SimulationConfigGenerator now routes unknown entity/poster fallback labels through the active locale instead of leaking hardcoded English `Unknown` into zh-mode config output.", + "fork_issue_mirrored": true, + "fork_issue_number": 8, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/8" + }, + { + "number": 110, + "title": "阿里云百炼 API 调用异常:付费计划(Coding Plan)非千文模型及大模型API中转站的API均失效,仅免费额度模型或coding plan的千文模型可用", + "url": "https://github.com/666ghj/MiroFish/issues/110", + "state": "open", + "created_at": "2026-03-10T01:08:29Z", + "updated_at": "2026-03-10T06:40:37Z", + "closed_at": null, + "labels": [ + "LLM API" + ], + "author": "weilb", + "body_excerpt": "阿里云百炼 API 调用异常:付费计划(Coding Plan)非千文模型及大模型API中转站的API均失效,仅免费额度模型或coding plan的千文模型可用", + "comment_count": 1, + "recent_comments": [ + { + "author": "lukeliu95", + "created_at": "2026-03-10T06:40:37Z", + "updated_at": "2026-03-10T06:40:37Z", + "url": "https://github.com/666ghj/MiroFish/issues/110#issuecomment-4029067259", + "body_excerpt": "使用以下方式调用Coding Plan LLM_BASE_URL=https://coding.dashscope.aliyuncs.com/v1 LLM_MODEL_NAME=qwen3.5-plus" + } + ], + "local_coverage": { + "number": 110, + "status": "covered", + "summary": "The backend and docs now support direct OpenAI-compatible gateways plus OPENAI_* aliases, including a documented DashScope Coding Plan example, so users no longer need a provider-specific raw LLM setup path.", + "local_refs": [ + "README.md", + "README-EN.md", + ".env.example", + "backend/app/config.py", + "backend/app/api/graph.py", + "backend/tests/test_openai_compat_services.py" + ], + "validation": [ + "backend/tests/test_openai_compat_services.py::test_simulation_config_generator_missing_api_key_mentions_openai_alias", + "backend/tests/test_openai_compat_services.py::test_simulation_config_generator_missing_api_key_english_message" + ], + "notes": "Mirrored into fork issue tracking on March 11, 2026 after enabling issues on ivanzud/MiroFish." + }, + "local_status": "covered", + "local_summary": "The backend and docs now support direct OpenAI-compatible gateways plus OPENAI_* aliases, including a documented DashScope Coding Plan example, so users no longer need a provider-specific raw LLM setup path.", + "triage_status": "covered", + "summary": "The backend and docs now support direct OpenAI-compatible gateways plus OPENAI_* aliases, including a documented DashScope Coding Plan example, so users no longer need a provider-specific raw LLM setup path.", + "coverage_status": "covered", + "coverage_summary": "The backend and docs now support direct OpenAI-compatible gateways plus OPENAI_* aliases, including a documented DashScope Coding Plan example, so users no longer need a provider-specific raw LLM setup path.", + "fork_issue_mirrored": true, + "fork_issue_number": 9, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/9" + }, + { + "number": 106, + "title": "能否采用除了zep的别的知识图谱", + "url": "https://github.com/666ghj/MiroFish/issues/106", + "state": "open", + "created_at": "2026-03-09T13:55:59Z", + "updated_at": "2026-03-10T02:33:21Z", + "closed_at": null, + "labels": [], + "author": "paipaiio", + "body_excerpt": "如题所示,今天在跑的时候发现zep的免费额度被耗光了,能否添加使用本地部署的memv作为知识图谱", + "comment_count": 2, + "recent_comments": [ + { + "author": "addisjeams", + "created_at": "2026-03-09T17:29:02Z", + "updated_at": "2026-03-09T17:29:02Z", + "url": "https://github.com/666ghj/MiroFish/issues/106#issuecomment-4025506309", + "body_excerpt": "对,一开始半天都是网络报错,后来才发现是这个问题。必须要申请zep" + }, + { + "author": "xingjia10086", + "created_at": "2026-03-10T02:33:21Z", + "updated_at": "2026-03-10T02:33:21Z", + "url": "https://github.com/666ghj/MiroFish/issues/106#issuecomment-4028182818", + "body_excerpt": "作者写的很明确 必须依靠ZEP哈 我也碰到一样的问题 没办法 只能换一个KEY" + } + ], + "local_coverage": { + "number": 106, + "status": "tracked", + "summary": "Tracked under beads issue `mirofish-8eg`: requests for non-Zep graph backends are preserved locally, but implementing them safely requires a fresh graph-backend abstraction instead of wiring another provider into current graph/simulation flows ad hoc.", + "local_refs": [ + ".beads/issues.jsonl", + "docs/upstream-triage.md" + ], + "validation": [ + "tracking only" + ] + }, + "local_status": "tracked", + "local_summary": "Tracked under beads issue `mirofish-8eg`: requests for non-Zep graph backends are preserved locally, but implementing them safely requires a fresh graph-backend abstraction instead of wiring another provider into current graph/simulation flows ad hoc.", + "triage_status": "tracked", + "summary": "Tracked under beads issue `mirofish-8eg`: requests for non-Zep graph backends are preserved locally, but implementing them safely requires a fresh graph-backend abstraction instead of wiring another provider into current graph/simulation flows ad hoc.", + "coverage_status": "tracked", + "coverage_summary": "Tracked under beads issue `mirofish-8eg`: requests for non-Zep graph backends are preserved locally, but implementing them safely requires a fresh graph-backend abstraction instead of wiring another provider into current graph/simulation flows ad hoc.", + "fork_issue_mirrored": true, + "fork_issue_number": 10, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/10" + }, + { + "number": 109, + "title": "纯小白看到新闻后本机部署,但似乎Zep额度用完后不知道接下来咋办了", + "url": "https://github.com/666ghj/MiroFish/issues/109", + "state": "closed", + "created_at": "2026-03-09T17:33:51Z", + "updated_at": "2026-03-10T02:32:25Z", + "closed_at": "2026-03-10T02:32:24Z", + "labels": [ + "question" + ], + "author": "xingjia10086", + "body_excerpt": "<img width=\"1242\" height=\"707\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/c3322506-4644-4194-bf60-760ba929d415\" /> 作者你好,看到你的新闻之后,怀着巨大的好奇心本地部署了一下。 用的 Google 反重力很快就部署成功,完全是代码小白。 我只是把自己公众号和 AI 对话的几个 MD 文件传上去,然后 Zep API 在第三轮开始模拟阶段很快就报错,如截图所示。 请问我是升级付费Zep呢,还是等3月底重置,不想再来一次呀,的确某个环节内存飙升在Win系统下,起初就是感兴趣想体验下投喂自己的资料后(写了十多年的公众号和AI对话后梳理的一些MD文档),预测下你的系统和我今年会做的事会不会有一些预测的重合,感觉值得一试,但是碰到这个问题,期待回复解决…", + "comment_count": 3, + "recent_comments": [ + { + "author": "xingjia10086", + "created_at": "2026-03-09T17:46:16Z", + "updated_at": "2026-03-09T17:46:16Z", + "url": "https://github.com/666ghj/MiroFish/issues/109#issuecomment-4025632660", + "body_excerpt": "以下是 claude 给的方案: 立即恢复运行的方法 最快的方式是直接补充当前 API 的额度,或者在配置文件/环境变量里替换成另一个有余额的 Key,重启程序即可从中断处继续(前提是程序支持断点续跑)。 如果暂时没有额度,可以考虑的替代方案 换一个 Provider:如果你原来用的是 OpenAI,可以临时切到 Anthropic、Google Gemini、DeepSeek、阿里云百炼等,接口格式大多兼容 OpenAI SDK,改动量很小。 用本地模型顶上:用 Olla…" + }, + { + "author": "dosubot[bot]", + "created_at": "2026-03-09T17:48:20Z", + "updated_at": "2026-03-09T17:48:20Z", + "url": "https://github.com/666ghj/MiroFish/issues/109#issuecomment-4025646271", + "body_excerpt": "<!-- Answer --> Claude 给的建议大方向是对的,但针对 MiroFish 的实际情况,有几点需要补充说明: **关于 Zep API 额度问题**:目前 MiroFish [只支持 Zep Cloud 作为图谱记忆后端](https://github.com/666ghj/MiroFish/blob/985f89f49acbb44ee14d9d680682c741a44eeebe/backend/app/config.py#L1-L76),没有替代方案。…" + }, + { + "author": "xingjia10086", + "created_at": "2026-03-10T02:32:25Z", + "updated_at": "2026-03-10T02:32:25Z", + "url": "https://github.com/666ghj/MiroFish/issues/109#issuecomment-4028178070", + "body_excerpt": "重新修改新的API泡一下 谢谢" + } + ], + "local_coverage": { + "number": 109, + "status": "covered", + "summary": "The home upload flow and quick-start docs now explicitly recommend starting with smaller source material (roughly 10k words) and about 30 simulation rounds on a first run, giving new local users a lower-cost way to verify the graph and runtime chain before they burn through Zep free-plan quota.", + "local_refs": [ + "frontend/src/views/Home.vue", + "frontend/src/i18n/locales/zh.js", + "frontend/src/i18n/locales/en.js", + "README.md", + "README-EN.md", + "https://github.com/ivanzud/MiroFish/issues/43" + ], + "validation": [ + "npm --prefix frontend run build", + "rg -n \"10k words|30 rounds|1 万字以内|30 轮左右\" frontend/src/views/Home.vue frontend/src/i18n/locales/zh.js frontend/src/i18n/locales/en.js README.md README-EN.md" + ], + "notes": "Mirrored into fork issue #43 on March 11, 2026. This backfills the same first-run quota guidance already tracked under upstream issue #19." + }, + "local_status": "covered", + "local_summary": "The home upload flow and quick-start docs now explicitly recommend starting with smaller source material (roughly 10k words) and about 30 simulation rounds on a first run, giving new local users a lower-cost way to verify the graph and runtime chain before they burn through Zep free-plan quota.", + "triage_status": "covered", + "summary": "The home upload flow and quick-start docs now explicitly recommend starting with smaller source material (roughly 10k words) and about 30 simulation rounds on a first run, giving new local users a lower-cost way to verify the graph and runtime chain before they burn through Zep free-plan quota.", + "coverage_status": "covered", + "coverage_summary": "The home upload flow and quick-start docs now explicitly recommend starting with smaller source material (roughly 10k words) and about 30 simulation rounds on a first run, giving new local users a lower-cost way to verify the graph and runtime chain before they burn through Zep free-plan quota.", + "fork_issue_mirrored": true, + "fork_issue_number": 43, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/43" + }, + { + "number": 42, + "title": "项目在3/5开始模拟时会消耗大量内存", + "url": "https://github.com/666ghj/MiroFish/issues/42", + "state": "open", + "created_at": "2026-01-21T01:57:50Z", + "updated_at": "2026-03-09T17:28:20Z", + "closed_at": null, + "labels": [], + "author": "s1f102500012", + "body_excerpt": "作为可能会用到的信息,我上传了大约有260000字符的《白夜行》前十二章。推测原因是simulation.py的接口会把所有动作读全量并返回,这些动作会随着模拟变大而线性膨胀,从而导致巨量内存消耗。 <img width=\"988\" height=\"666\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/3fce3699-5e8d-4a8c-a33f-5290b236a2f0\" />", + "comment_count": 2, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-01-21T07:14:28Z", + "updated_at": "2026-01-21T07:14:28Z", + "url": "https://github.com/666ghj/MiroFish/issues/42#issuecomment-3776532125", + "body_excerpt": "我后续会进行算法层面的优化" + }, + { + "author": "xingjia10086", + "created_at": "2026-03-09T17:28:20Z", + "updated_at": "2026-03-09T17:28:20Z", + "url": "https://github.com/666ghj/MiroFish/issues/42#issuecomment-4025501526", + "body_excerpt": "你的Zep的API够用吗 ?" + } + ], + "local_coverage": { + "number": 42, + "status": "covered", + "summary": "Step 3 detailed run polling is now incremental and bounded, so the frontend no longer re-downloads the entire simulation timeline every few seconds as a run grows.", + "local_refs": [ + "backend/app/api/simulation.py", + "frontend/src/components/Step3Simulation.vue", + "frontend/src/components/simulationTimeline.js" + ], + "validation": [ + "scripts/test_backend_lite.sh", + "frontend: npm test", + "frontend: npm run build" + ] + }, + "local_status": "covered", + "local_summary": "Step 3 detailed run polling is now incremental and bounded, so the frontend no longer re-downloads the entire simulation timeline every few seconds as a run grows.", + "triage_status": "covered", + "summary": "Step 3 detailed run polling is now incremental and bounded, so the frontend no longer re-downloads the entire simulation timeline every few seconds as a run grows.", + "coverage_status": "covered", + "coverage_summary": "Step 3 detailed run polling is now incremental and bounded, so the frontend no longer re-downloads the entire simulation timeline every few seconds as a run grows.", + "fork_issue_mirrored": true, + "fork_issue_number": 11, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/11" + }, + { + "number": 84, + "title": "报告生成失败,请问有没有办法重新生成?", + "url": "https://github.com/666ghj/MiroFish/issues/84", + "state": "open", + "created_at": "2026-03-08T08:10:41Z", + "updated_at": "2026-03-09T17:26:28Z", + "closed_at": null, + "labels": [ + "question" + ], + "author": "luchenwei9266", + "body_excerpt": "<img width=\"2538\" height=\"1213\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/e3d01822-aa09-45b7-9210-2ce8e23bca8f\" /> 好像是Zep超出调用限额导致报告生成失败,但是即使我充值了Zep,似乎也没有办法重新生成报告,难道只能重新走一轮?", + "comment_count": 2, + "recent_comments": [ + { + "author": "dosubot[bot]", + "created_at": "2026-03-08T08:12:58Z", + "updated_at": "2026-03-08T08:12:58Z", + "url": "https://github.com/666ghj/MiroFish/issues/84#issuecomment-4018615761", + "body_excerpt": "<!-- Greeting --> Hi @luchenwei9266! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the MiroFish team. <!-- Answer --> 目前 MiroFish **不支持通过界面重试报告生成**,这是一个[已知问题](https://github.com/666ghj/MiroFish/issues/30)。系统虽然有 `force_regenerate`…" + }, + { + "author": "xingjia10086", + "created_at": "2026-03-09T17:24:34Z", + "updated_at": "2026-03-09T17:26:28Z", + "url": "https://github.com/666ghj/MiroFish/issues/84#issuecomment-4025475607", + "body_excerpt": "<img width=\"1242\" height=\"707\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/871bbcaa-85e2-4f1c-a714-cc038fdf6fd7\" /> 我这跑了一个任务还没结束 ZEP的API就用完了 用了默认的设置 40 ,现在很尴尬的状态 接下来应该怎么才能继续呢? @dosu 难道只能重新注册一个Zep 再跑一遍流程吗?" + } + ], + "local_coverage": { + "number": 84, + "status": "covered", + "summary": "Failed report generation can now be retried directly from Step 4 with persisted status polling, backend error text, and a `force_regenerate` retry path.", + "local_refs": [ + "frontend/src/components/Step4Report.vue", + "frontend/src/api/report.js" + ], + "validation": [ + "frontend: npm run build" + ] + }, + "local_status": "covered", + "local_summary": "Failed report generation can now be retried directly from Step 4 with persisted status polling, backend error text, and a `force_regenerate` retry path.", + "triage_status": "covered", + "summary": "Failed report generation can now be retried directly from Step 4 with persisted status polling, backend error text, and a `force_regenerate` retry path.", + "coverage_status": "covered", + "coverage_summary": "Failed report generation can now be retried directly from Step 4 with persisted status polling, backend error text, and a `force_regenerate` retry path.", + "fork_issue_mirrored": true, + "fork_issue_number": 12, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/12" + }, + { + "number": 107, + "title": "镜像问题", + "url": "https://github.com/666ghj/MiroFish/issues/107", + "state": "open", + "created_at": "2026-03-09T14:44:45Z", + "updated_at": "2026-03-09T14:44:45Z", + "closed_at": null, + "labels": [], + "author": "zhuhw19", + "body_excerpt": "✘ Image ghcr.io/666ghj/mirofish:latest Error Get \"https://ghcr.io/v2/\": EOF 7.9s Error response from daemon: Get \"https://ghcr.io/v2/\": EOF", + "comment_count": 0, + "recent_comments": [], + "local_coverage": { + "number": 107, + "status": "covered", + "summary": "Docker deployment now reads `MIROFISH_IMAGE` from `.env` or the shell, so GHCR pull failures can be worked around with a mirror/private registry override instead of editing `docker-compose.yml`.", + "local_refs": [ + "docker-compose.yml", + ".env.example", + "README.md", + "README-EN.md", + "README-JA.md", + "README-KO.md" + ], + "validation": [ + "docker compose config" + ], + "notes": "Mirrored into fork issue tracking on March 11, 2026 after enabling issues on ivanzud/MiroFish." + }, + "local_status": "covered", + "local_summary": "Docker deployment now reads `MIROFISH_IMAGE` from `.env` or the shell, so GHCR pull failures can be worked around with a mirror/private registry override instead of editing `docker-compose.yml`.", + "triage_status": "covered", + "summary": "Docker deployment now reads `MIROFISH_IMAGE` from `.env` or the shell, so GHCR pull failures can be worked around with a mirror/private registry override instead of editing `docker-compose.yml`.", + "coverage_status": "covered", + "coverage_summary": "Docker deployment now reads `MIROFISH_IMAGE` from `.env` or the shell, so GHCR pull failures can be worked around with a mirror/private registry override instead of editing `docker-compose.yml`.", + "fork_issue_mirrored": true, + "fork_issue_number": 13, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/13" + }, + { + "number": 96, + "title": "数字世界agent回复内容异常", + "url": "https://github.com/666ghj/MiroFish/issues/96", + "state": "closed", + "created_at": "2026-03-09T03:04:27Z", + "updated_at": "2026-03-09T11:48:19Z", + "closed_at": "2026-03-09T05:21:15Z", + "labels": [ + "question" + ], + "author": "Lilingjie520", + "body_excerpt": "<img width=\"1552\" height=\"1046\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/45b71afe-55cc-4bc7-8d66-df3cd2064aac\" />,使用demo的任务,生成报告后,和agent对话,发现它的回复是机械的重复。", + "comment_count": 3, + "recent_comments": [ + { + "author": "MomentOfUs", + "created_at": "2026-03-09T03:49:00Z", + "updated_at": "2026-03-09T03:49:00Z", + "url": "https://github.com/666ghj/MiroFish/issues/96#issuecomment-4020909732", + "body_excerpt": "demo 是静态网页,没有接入LLM" + }, + { + "author": "666ghj", + "created_at": "2026-03-09T05:21:13Z", + "updated_at": "2026-03-09T05:21:13Z", + "url": "https://github.com/666ghj/MiroFish/issues/96#issuecomment-4021237510", + "body_excerpt": "这是**预期行为,不是bug**。 [在线Demo是一个静态页面](https://github.com/666ghj/MiroFish/issues/88),**不与任何LLM集成**。它使用预先准备的静态模板响应,仅用于让用户体验系统界面,因此无论问什么问题,回答都是一样的。 如果想获得真正的动态AI回答,需要**本地部署MiroFish系统**并配置真实的LLM API(如deepseek-chat、gemini-2.5-pro、qwen-plus等)。" + }, + { + "author": "Lilingjie520", + "created_at": "2026-03-09T11:48:19Z", + "updated_at": "2026-03-09T11:48:19Z", + "url": "https://github.com/666ghj/MiroFish/issues/96#issuecomment-4023193329", + "body_excerpt": "收到感谢 On Mon, Mar 9, 2026 at 1:21 PM BaiFu ***@***.***> wrote: > Closed #96 <https://github.com/666ghj/MiroFish/issues/96> as completed. > > — > Reply to this email directly, view it on GitHub > <https://github.com/666ghj/MiroFish/issues/96…" + } + ], + "triage_status": "untracked", + "summary": "<img width=\"1552\" height=\"1046\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/45b71afe-55cc-4bc7-8d66-df3cd2064aac\" />,使用demo的任务,生成报告后,和agent对话,发现它的回复是机械的重复。", + "coverage_status": "untracked", + "coverage_summary": "<img width=\"1552\" height=\"1046\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/45b71afe-55cc-4bc7-8d66-df3cd2064aac\" />,使用demo的任务,生成报告后,和agent对话,发现它的回复是机械的重复。", + "fork_issue_mirrored": true, + "fork_issue_number": 44, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/44" + }, + { + "number": 99, + "title": "Docker镜像没有arm版本", + "url": "https://github.com/666ghj/MiroFish/issues/99", + "state": "open", + "created_at": "2026-03-09T06:52:35Z", + "updated_at": "2026-03-09T06:54:58Z", + "closed_at": null, + "labels": [ + "enhancement" + ], + "author": "linqiu919", + "body_excerpt": "arm架构机器使用docker-compose部署提示: `no matching manifest for linux/arm64/v8 in the manifest list entries ` 可以调整一下工作流的脚本,支持一下么?", + "comment_count": 1, + "recent_comments": [ + { + "author": "dosubot[bot]", + "created_at": "2026-03-09T06:54:58Z", + "updated_at": "2026-03-09T06:54:58Z", + "url": "https://github.com/666ghj/MiroFish/issues/99#issuecomment-4021549881", + "body_excerpt": "<!-- Answer --> 好消息!当前的工作流 [`.github/workflows/docker-image.yml`](https://github.com/666ghj/MiroFish/blob/985f89f49acbb44ee14d9d680682c741a44eeebe/.github/workflows/docker-image.yml) 已经配置了 QEMU 和 Buildx(支持多架构构建的必要组件),只需要在构建步骤中添加 `platforms…" + } + ], + "local_coverage": { + "number": 99, + "status": "covered", + "summary": "Docker image publishing now builds both `linux/amd64` and `linux/arm64`, so the missing ARM image issue is already resolved locally.", + "local_refs": [ + ".github/workflows/docker-image.yml" + ], + "validation": [ + "workflow review only" + ] + }, + "local_status": "covered", + "local_summary": "Docker image publishing now builds both `linux/amd64` and `linux/arm64`, so the missing ARM image issue is already resolved locally.", + "triage_status": "covered", + "summary": "Docker image publishing now builds both `linux/amd64` and `linux/arm64`, so the missing ARM image issue is already resolved locally.", + "coverage_status": "covered", + "coverage_summary": "Docker image publishing now builds both `linux/amd64` and `linux/arm64`, so the missing ARM image issue is already resolved locally.", + "fork_issue_mirrored": true, + "fork_issue_number": 14, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/14" + }, + { + "number": 98, + "title": "test", + "url": "https://github.com/666ghj/MiroFish/issues/98", + "state": "closed", + "created_at": "2026-03-09T04:15:56Z", + "updated_at": "2026-03-09T05:20:16Z", + "closed_at": "2026-03-09T05:20:16Z", + "labels": [], + "author": "leon-x-labs", + "body_excerpt": "", + "comment_count": 0, + "recent_comments": [], + "triage_status": "untracked", + "summary": "", + "coverage_status": "untracked", + "coverage_summary": "", + "fork_issue_mirrored": true, + "fork_issue_number": 45, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/45" + }, + { + "number": 93, + "title": "`frontend/src/api/index.js`中的`baseURL`不应该硬编码", + "url": "https://github.com/666ghj/MiroFish/issues/93", + "state": "open", + "created_at": "2026-03-08T16:44:24Z", + "updated_at": "2026-03-09T01:53:47Z", + "closed_at": null, + "labels": [ + "enhancement" + ], + "author": "HexStan", + "body_excerpt": "https://github.com/666ghj/MiroFish/blob/985f89f49acbb44ee14d9d680682c741a44eeebe/frontend/src/api/index.js#L5 如#59 #57 所发现的一样,在上传文件时前端会尝试跳转硬编码的`localhost:5001`,这就意味着我只能在部署mirofish的本机上使用,并且在Docker部署时也不能映射其他的端口,对服务器环境很不友好。我代码水平不够,不知道应该怎么修,所以希望作者可以修一下,谢谢!", + "comment_count": 1, + "recent_comments": [ + { + "author": "hxx221", + "created_at": "2026-03-09T01:53:47Z", + "updated_at": "2026-03-09T01:53:47Z", + "url": "https://github.com/666ghj/MiroFish/issues/93#issuecomment-4020607805", + "body_excerpt": "VITE_API_BASE_URL 你修改这个配置就可以了吧,那个5001是容错的,没有VITE_API_BASE_URL配置才会启用这个吧" + } + ], + "local_coverage": { + "number": 93, + "status": "covered", + "summary": "The frontend no longer hardcodes `http://localhost:5001` in project-init messaging and instead uses the shared API base-url resolver.", + "local_refs": [ + "frontend/src/views/Process.vue", + "frontend/src/api/baseUrl.js" + ], + "validation": [ + "frontend/tests/baseUrl.test.mjs", + "frontend: npm run build" + ] + }, + "local_status": "covered", + "local_summary": "The frontend no longer hardcodes `http://localhost:5001` in project-init messaging and instead uses the shared API base-url resolver.", + "triage_status": "covered", + "summary": "The frontend no longer hardcodes `http://localhost:5001` in project-init messaging and instead uses the shared API base-url resolver.", + "coverage_status": "covered", + "coverage_summary": "The frontend no longer hardcodes `http://localhost:5001` in project-init messaging and instead uses the shared API base-url resolver.", + "fork_issue_mirrored": true, + "fork_issue_number": 15, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/15" + }, + { + "number": 95, + "title": "为什么回答一模一样?", + "url": "https://github.com/666ghj/MiroFish/issues/95", + "state": "closed", + "created_at": "2026-03-09T01:02:45Z", + "updated_at": "2026-03-09T01:11:44Z", + "closed_at": "2026-03-09T01:11:44Z", + "labels": [ + "question" + ], + "author": "YHLpuyu", + "body_excerpt": "<img width=\"1028\" height=\"758\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/3a616f13-e734-479a-becf-2da11d6a6d6f\" /> <img width=\"1060\" height=\"754\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/e19555e9-9a71-4da7-9704-8a48d2674832\" /> 在线的示例中,不论提问什么,回答都一样。", + "comment_count": 1, + "recent_comments": [ + { + "author": "dosubot[bot]", + "created_at": "2026-03-09T01:09:47Z", + "updated_at": "2026-03-09T01:09:47Z", + "url": "https://github.com/666ghj/MiroFish/issues/95#issuecomment-4020483535", + "body_excerpt": "<!-- Answer --> 这是**预期行为,不是bug**。 [在线Demo是一个静态页面](https://github.com/666ghj/MiroFish/issues/88),**不与任何LLM集成**。它使用预先准备的静态模板响应,仅用于让用户体验系统界面,因此无论问什么问题,回答都是一样的。 如果想获得真正的动态AI回答,需要**本地部署MiroFish系统**并配置真实的LLM API(如deepseek-chat、gemini-2.5-pro、qwe…" + } + ], + "triage_status": "untracked", + "summary": "<img width=\"1028\" height=\"758\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/3a616f13-e734-479a-becf-2da11d6a6d6f\" /> <img width=\"1060\" height=\"754\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/e19555e9-9a71-4da7-9704-8a48d2674832\" /> 在线的示例中,不论提问什么,回答都一样。", + "coverage_status": "untracked", + "coverage_summary": "<img width=\"1028\" height=\"758\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/3a616f13-e734-479a-becf-2da11d6a6d6f\" /> <img width=\"1060\" height=\"754\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/e19555e9-9a71-4da7-9704-8a48d2674832\" /> 在线的示例中,不论提问什么,回答都一样。", + "fork_issue_mirrored": true, + "fork_issue_number": 46, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/46" + }, + { + "number": 94, + "title": "测试issue", + "url": "https://github.com/666ghj/MiroFish/issues/94", + "state": "closed", + "created_at": "2026-03-09T00:51:55Z", + "updated_at": "2026-03-09T00:52:24Z", + "closed_at": "2026-03-09T00:52:24Z", + "labels": [], + "author": "breadbot86", + "body_excerpt": "这是一个测试issue,用来验证gh cli是否可以给非自己项目提issue。", + "comment_count": 0, + "recent_comments": [], + "triage_status": "untracked", + "summary": "这是一个测试issue,用来验证gh cli是否可以给非自己项目提issue。", + "coverage_status": "untracked", + "coverage_summary": "这是一个测试issue,用来验证gh cli是否可以给非自己项目提issue。", + "fork_issue_mirrored": true, + "fork_issue_number": 47, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/47" + }, + { + "number": 92, + "title": "Upgrade GitHub Actions", + "url": "https://github.com/666ghj/MiroFish/issues/92", + "state": "open", + "created_at": "2026-03-08T15:26:04Z", + "updated_at": "2026-03-08T15:28:09Z", + "closed_at": null, + "labels": [ + "enhancement" + ], + "author": "leon-x-labs", + "body_excerpt": "", + "comment_count": 0, + "recent_comments": [], + "local_coverage": { + "number": 92, + "status": "covered", + "summary": "The current GitHub Actions workflow already includes the later upgrade sweep from upstream PR #116, so this issue is stale on this branch.", + "local_refs": [ + ".github/workflows/docker-image.yml" + ], + "validation": [ + "workflow review only" + ] + }, + "local_status": "covered", + "local_summary": "The current GitHub Actions workflow already includes the later upgrade sweep from upstream PR #116, so this issue is stale on this branch.", + "triage_status": "covered", + "summary": "The current GitHub Actions workflow already includes the later upgrade sweep from upstream PR #116, so this issue is stale on this branch.", + "coverage_status": "covered", + "coverage_summary": "The current GitHub Actions workflow already includes the later upgrade sweep from upstream PR #116, so this issue is stale on this branch.", + "fork_issue_mirrored": true, + "fork_issue_number": 16, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/16" + }, + { + "number": 90, + "title": "预测的很好!!!", + "url": "https://github.com/666ghj/MiroFish/issues/90", + "state": "closed", + "created_at": "2026-03-08T12:10:00Z", + "updated_at": "2026-03-08T12:27:39Z", + "closed_at": "2026-03-08T12:27:39Z", + "labels": [], + "author": "betatoo", + "body_excerpt": "<img width=\"2136\" height=\"1726\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/e75c2ec9-0eed-45f5-be11-bfc7114eb41a\" />", + "comment_count": 1, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-03-08T12:27:35Z", + "updated_at": "2026-03-08T12:27:35Z", + "url": "https://github.com/666ghj/MiroFish/issues/90#issuecomment-4018961124", + "body_excerpt": "这是演示demo,静态页面,没有接入LLM,agent的回答是准备好的静态模版,只是供大家体验一下这个系统,如果你想要深度体验需要自己部署。 另外就算是你自己部署的,这个传入资料也是武汉大学的分析资料吧,你让他预测什么战争也不行的。 我们的网站写了,体验一次静态演示demo" + } + ], + "triage_status": "untracked", + "summary": "<img width=\"2136\" height=\"1726\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/e75c2ec9-0eed-45f5-be11-bfc7114eb41a\" />", + "coverage_status": "untracked", + "coverage_summary": "<img width=\"2136\" height=\"1726\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/e75c2ec9-0eed-45f5-be11-bfc7114eb41a\" />", + "fork_issue_mirrored": true, + "fork_issue_number": 48, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/48" + }, + { + "number": 88, + "title": "问卷调查1+1=?回答是认真的吗", + "url": "https://github.com/666ghj/MiroFish/issues/88", + "state": "closed", + "created_at": "2026-03-08T12:04:11Z", + "updated_at": "2026-03-08T12:07:19Z", + "closed_at": "2026-03-08T12:07:19Z", + "labels": [ + "Q&A" + ], + "author": "huahuayu", + "body_excerpt": "AI的回答讲空话,一点实质的内容都没有 <img width=\"1552\" height=\"1630\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/8151b035-c2f4-450a-b9d4-66517dfa940c\" />", + "comment_count": 1, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-03-08T12:06:16Z", + "updated_at": "2026-03-08T12:06:16Z", + "url": "https://github.com/666ghj/MiroFish/issues/88#issuecomment-4018930177", + "body_excerpt": "这是演示demo,静态页面,没有接入LLM,只是供大家体验一下这个系统,如果你想要深度体验需要自己部署。 > AI的回答讲空话,一点实质的内容都没有 > > <img alt=\"Image\" width=\"1552\" height=\"1630\" src=\"https://private-user-images.githubusercontent.com/1725060/559907615-8151b035-c2f4-450a-b9d4-66517dfa940c.png?jw…" + } + ], + "triage_status": "untracked", + "summary": "AI的回答讲空话,一点实质的内容都没有 <img width=\"1552\" height=\"1630\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/8151b035-c2f4-450a-b9d4-66517dfa940c\" />", + "coverage_status": "untracked", + "coverage_summary": "AI的回答讲空话,一点实质的内容都没有 <img width=\"1552\" height=\"1630\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/8151b035-c2f4-450a-b9d4-66517dfa940c\" />", + "fork_issue_mirrored": true, + "fork_issue_number": 49, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/49" + }, + { + "number": 80, + "title": "Lácteos CCC", + "url": "https://github.com/666ghj/MiroFish/issues/80", + "state": "closed", + "created_at": "2026-03-07T20:24:42Z", + "updated_at": "2026-03-08T04:00:16Z", + "closed_at": "2026-03-08T04:00:16Z", + "labels": [], + "author": "cristian9777", + "body_excerpt": "", + "comment_count": 0, + "recent_comments": [], + "triage_status": "untracked", + "summary": "", + "coverage_status": "untracked", + "coverage_summary": "", + "fork_issue_mirrored": true, + "fork_issue_number": 50, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/50" + }, + { + "number": 83, + "title": "Demo test-report test is not complete.", + "url": "https://github.com/666ghj/MiroFish/issues/83", + "state": "closed", + "created_at": "2026-03-08T03:34:29Z", + "updated_at": "2026-03-08T03:59:46Z", + "closed_at": "2026-03-08T03:59:46Z", + "labels": [], + "author": "yizhi1523-art", + "body_excerpt": "the report chat is not complete. <img width=\"1007\" height=\"433\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/37b72910-aba1-4f2b-ac01-c45975a030a5\" />", + "comment_count": 2, + "recent_comments": [ + { + "author": "dosubot[bot]", + "created_at": "2026-03-08T03:36:49Z", + "updated_at": "2026-03-08T03:36:49Z", + "url": "https://github.com/666ghj/MiroFish/issues/83#issuecomment-4018134520", + "body_excerpt": "<!-- Greeting --> Hi @yizhi1523-art! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the MiroFish team. <!-- Answer --> This is a known issue! The Report Agent duplicate/repetitive responses typically stem from a few causes: **Most…" + }, + { + "author": "666ghj", + "created_at": "2026-03-08T03:59:44Z", + "updated_at": "2026-03-08T03:59:44Z", + "url": "https://github.com/666ghj/MiroFish/issues/83#issuecomment-4018225059", + "body_excerpt": "This demo consists of static pages for demonstration purposes only and is not integrated with an LLM. For a full experience, local deployment is recommended." + } + ], + "triage_status": "untracked", + "summary": "the report chat is not complete. <img width=\"1007\" height=\"433\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/37b72910-aba1-4f2b-ac01-c45975a030a5\" />", + "coverage_status": "untracked", + "coverage_summary": "the report chat is not complete. <img width=\"1007\" height=\"433\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/37b72910-aba1-4f2b-ac01-c45975a030a5\" />", + "fork_issue_mirrored": true, + "fork_issue_number": 51, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/51" + }, + { + "number": 78, + "title": "有mirofish和前作bettafish的场景/性能对比吗", + "url": "https://github.com/666ghj/MiroFish/issues/78", + "state": "closed", + "created_at": "2026-03-07T03:08:43Z", + "updated_at": "2026-03-07T03:16:07Z", + "closed_at": "2026-03-07T03:16:07Z", + "labels": [ + "question" + ], + "author": "flappy5812", + "body_excerpt": "从L站帖子来的,首先感谢大佬贡献开源项目 想问下有mirofish和前作bettafish的场景/性能对比吗", + "comment_count": 1, + "recent_comments": [ + { + "author": "dosubot[bot]", + "created_at": "2026-03-07T03:12:50Z", + "updated_at": "2026-03-07T03:12:50Z", + "url": "https://github.com/666ghj/MiroFish/issues/78#issuecomment-4015386647", + "body_excerpt": "<!-- Greeting --> Hi @flappy5812! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the MiroFish team. <!-- Answer --> 感谢关注!MiroFish 和 BettaFish 是**互补关系**而非替代关系,维护者用一个比喻形容:[**\"BettaFish 是后视镜,MiroFish 是望远镜\"**](https://github.com/666gh…" + } + ], + "triage_status": "untracked", + "summary": "从L站帖子来的,首先感谢大佬贡献开源项目 想问下有mirofish和前作bettafish的场景/性能对比吗", + "coverage_status": "untracked", + "coverage_summary": "从L站帖子来的,首先感谢大佬贡献开源项目 想问下有mirofish和前作bettafish的场景/性能对比吗", + "fork_issue_mirrored": true, + "fork_issue_number": 52, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/52" + }, + { + "number": 56, + "title": "Zep 本地化实现方案交流专贴", + "url": "https://github.com/666ghj/MiroFish/issues/56", + "state": "open", + "created_at": "2026-01-23T07:24:57Z", + "updated_at": "2026-03-06T01:40:22Z", + "closed_at": null, + "labels": [], + "author": "666ghj", + "body_excerpt": "很多小伙伴已经实现了自己的 Zep 本地化版本,这非常酷!为了方便社区查阅及参考,特开此 Issue 进行统一的展示与讨论。", + "comment_count": 7, + "recent_comments": [ + { + "author": "jstdoit", + "created_at": "2026-01-23T07:40:33Z", + "updated_at": "2026-01-23T07:40:33Z", + "url": "https://github.com/666ghj/MiroFish/issues/56#issuecomment-3788811442", + "body_excerpt": "好多小伙伴虽然实现了,但是效果一般啊,很多效果都没达到zep的" + }, + { + "author": "666ghj", + "created_at": "2026-01-23T07:43:29Z", + "updated_at": "2026-01-23T07:45:28Z", + "url": "https://github.com/666ghj/MiroFish/issues/56#issuecomment-3788820464", + "body_excerpt": "Issue: https://github.com/666ghj/MiroFish/issues/55#issue-3845610782 https://github.com/666ghj/MiroFish/issues/41#issuecomment-3776456714 https://github.com/666ghj/MiroFish/issues/35#issue-3828698112 PR: https://github.com/666ghj/MiroFish/…" + }, + { + "author": "666ghj", + "created_at": "2026-01-23T07:44:09Z", + "updated_at": "2026-01-23T07:44:09Z", + "url": "https://github.com/666ghj/MiroFish/issues/56#issuecomment-3788822532", + "body_excerpt": "> 好多小伙伴虽然实现了,但是效果一般啊,很多效果都没达到zep的 是的,可以再广泛的调研一下,agent记忆方面的论文很多" + } + ], + "local_coverage": { + "number": 56, + "status": "reference", + "summary": "Upstream issue #56 is a community discussion thread collecting localized Zep alternatives. It is retained as reference material while any concrete backend-abstraction work stays tracked under `mirofish-8eg`.", + "local_refs": [ + ".beads/issues.jsonl", + "docs/upstream-open-state.json" + ], + "validation": [ + "triage only" + ] + }, + "local_status": "reference", + "local_summary": "Upstream issue #56 is a community discussion thread collecting localized Zep alternatives. It is retained as reference material while any concrete backend-abstraction work stays tracked under `mirofish-8eg`.", + "triage_status": "reference", + "summary": "Upstream issue #56 is a community discussion thread collecting localized Zep alternatives. It is retained as reference material while any concrete backend-abstraction work stays tracked under `mirofish-8eg`.", + "coverage_status": "reference", + "coverage_summary": "Upstream issue #56 is a community discussion thread collecting localized Zep alternatives. It is retained as reference material while any concrete backend-abstraction work stays tracked under `mirofish-8eg`.", + "fork_issue_mirrored": true, + "fork_issue_number": 17, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/17" + }, + { + "number": 48, + "title": "在按照格式填写api等数据并写入环境变量后仍然无法进行第一步", + "url": "https://github.com/666ghj/MiroFish/issues/48", + "state": "closed", + "created_at": "2026-01-21T16:01:08Z", + "updated_at": "2026-03-05T16:28:05Z", + "closed_at": "2026-03-05T16:28:05Z", + "labels": [], + "author": "qi-1021", + "body_excerpt": "[![IMG-20260121-235102.jpg](https://i.postimg.cc/CKPT5D0L/IMG-20260121-235102.jpg)](https://postimg.cc/B8Kwwjtd) 如图所示 我使用了开发者推荐的通义百炼api,不过是免费额度。 本人对代码一知半解,希望有人能帮助我", + "comment_count": 1, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-03-05T16:27:37Z", + "updated_at": "2026-03-05T16:27:37Z", + "url": "https://github.com/666ghj/MiroFish/issues/48#issuecomment-4006299188", + "body_excerpt": "> [![IMG-20260121-235102.jpg](https://camo.githubusercontent.com/2eea27bc5db72960b3169026ed7cfc0e2fde2ad41cb123186566679f3e91eb20/68747470733a2f2f692e706f7374696d672e63632f434b50543544304c2f494d472d32303236303132312d3233353130322e6a7067)](…" + } + ], + "triage_status": "untracked", + "summary": "[![IMG-20260121-235102.jpg](https://i.postimg.cc/CKPT5D0L/IMG-20260121-235102.jpg)](https://postimg.cc/B8Kwwjtd) 如图所示 我使用了开发者推荐的通义百炼api,不过是免费额度。 本人对代码一知半解,希望有人能帮助我", + "coverage_status": "untracked", + "coverage_summary": "[![IMG-20260121-235102.jpg](https://i.postimg.cc/CKPT5D0L/IMG-20260121-235102.jpg)](https://postimg.cc/B8Kwwjtd) 如图所示 我使用了开发者推荐的通义百炼api,不过是免费额度。 本人对代码一知半解,希望有人能帮助我", + "fork_issue_mirrored": true, + "fork_issue_number": 53, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/53" + }, + { + "number": 77, + "title": "国内部署 MiroFish,Zep API Key 认证持续失败", + "url": "https://github.com/666ghj/MiroFish/issues/77", + "state": "closed", + "created_at": "2026-02-28T14:45:15Z", + "updated_at": "2026-03-05T05:25:02Z", + "closed_at": "2026-03-05T05:25:02Z", + "labels": [ + "LLM API" + ], + "author": "bolin0759", + "body_excerpt": "环境: - 服务器:中国北京(京东云) - MiroFish 版本:v0.1.0(源码部署) - Zep Key 格式:JWT Token (z_xxx.yyy.zzz) - 测试结果:所有 Key 返回 \"unauthorized\" 国内访问 Zep Cloud 是否有代理要求? <img width=\"808\" height=\"314\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/97190efc-6559-43ca-87e9-304bda3ddafd\" />", + "comment_count": 3, + "recent_comments": [ + { + "author": "dosubot[bot]", + "created_at": "2026-02-28T14:47:16Z", + "updated_at": "2026-02-28T14:47:16Z", + "url": "https://github.com/666ghj/MiroFish/issues/77#issuecomment-3977261623", + "body_excerpt": "<!-- Greeting --> Hi @bolin0759! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the MiroFish team. <!-- Answer --> 你好!根据代码库的信息,国内访问 Zep Cloud **确实需要代理配置**。 [BettaFish 项目明确说明在中国大陆需要配置代理/VPN 才能访问国外 API 服务](https://github.com/666ghj/…" + }, + { + "author": "666ghj", + "created_at": "2026-02-28T18:30:43Z", + "updated_at": "2026-02-28T18:30:43Z", + "url": "https://github.com/666ghj/MiroFish/issues/77#issuecomment-3977545754", + "body_excerpt": "我记得是不需要的,不要用v0.1.0版本,使用最新commit的代码重新运行看看" + }, + { + "author": "666ghj", + "created_at": "2026-03-03T16:51:38Z", + "updated_at": "2026-03-03T16:51:38Z", + "url": "https://github.com/666ghj/MiroFish/issues/77#issuecomment-3992304474", + "body_excerpt": "我这边最新版本代码国内环境是可以正常使用zep的" + } + ], + "local_coverage": { + "number": 77, + "status": "covered", + "summary": "Graph-build task failures now classify Zep 401/unauthorized responses into a concise ZEP_API_KEY guidance message and strip embedded traceback noise before returning task payload errors, so deployments behind domestic proxies or with invalid Zep credentials no longer fail with an opaque auth dump.", + "local_refs": [ + "backend/app/services/graph_builder.py", + "backend/tests/test_graph_builder.py", + "backend/tests/test_graph_upload_api.py", + "https://github.com/ivanzud/MiroFish/issues/54" + ], + "validation": [ + "cd backend && uv run pytest -q tests/test_graph_builder.py tests/test_graph_upload_api.py -k \"auth_error or unauthorized\"" + ], + "notes": "Mirrored into fork issue #54 on March 11, 2026. This backfills the same repo-native Zep auth hardening already captured for upstream issue #139." + }, + "local_status": "covered", + "local_summary": "Graph-build task failures now classify Zep 401/unauthorized responses into a concise ZEP_API_KEY guidance message and strip embedded traceback noise before returning task payload errors, so deployments behind domestic proxies or with invalid Zep credentials no longer fail with an opaque auth dump.", + "triage_status": "covered", + "summary": "Graph-build task failures now classify Zep 401/unauthorized responses into a concise ZEP_API_KEY guidance message and strip embedded traceback noise before returning task payload errors, so deployments behind domestic proxies or with invalid Zep credentials no longer fail with an opaque auth dump.", + "coverage_status": "covered", + "coverage_summary": "Graph-build task failures now classify Zep 401/unauthorized responses into a concise ZEP_API_KEY guidance message and strip embedded traceback noise before returning task payload errors, so deployments behind domestic proxies or with invalid Zep credentials no longer fail with an opaque auth dump.", + "fork_issue_mirrored": true, + "fork_issue_number": 54, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/54" + }, + { + "number": 68, + "title": "尝试进行首次模拟,等待了1夜还是在3/5的阶段", + "url": "https://github.com/666ghj/MiroFish/issues/68", + "state": "open", + "created_at": "2026-02-10T01:19:08Z", + "updated_at": "2026-03-01T08:29:56Z", + "closed_at": null, + "labels": [ + "question" + ], + "author": "GarretRen", + "body_excerpt": "<img width=\"1917\" height=\"1884\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/a3b1f1c4-b406-4918-8c7a-7065e7d8dabb\" /> 我尝试进行了一次模拟,选择40轮,需要进行多久呢?此处的60min每轮,代表需要真实世界的60min吗?昨天放了一晚上也没好,看起来控制台在重复输出这样的内容 <img width=\"1734\" height=\"2887\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/cf505633-93ae-432c-99a3-85fae480919d\" /> 另外我在尝试弄清楚发生了什么的时候,尝试打开后端api界面(http://localhost:5001/)…", + "comment_count": 2, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-02-28T08:06:05Z", + "updated_at": "2026-02-28T08:06:05Z", + "url": "https://github.com/666ghj/MiroFish/issues/68#issuecomment-3976635156", + "body_excerpt": "看一下后端文件夹中的模拟日志,类似于在backend/uploads/simulations/sim_id/simulation.log,控制台显示的是日志信息获取接口200,你这个应该是模拟任务出bug了,因为他内部用了机器学习模型做推荐算法,盲猜是从huggingface上拉模型的时候网络错误了。你有更多反馈的话可以贴出来,我再看看。" + }, + { + "author": "GarretRen", + "created_at": "2026-03-01T08:29:56Z", + "updated_at": "2026-03-01T08:29:56Z", + "url": "https://github.com/666ghj/MiroFish/issues/68#issuecomment-3979489505", + "body_excerpt": "感谢帮助,确实是网络问题。在使用全局vpn之后,问题解决了" + } + ], + "local_coverage": { + "number": 68, + "status": "covered", + "summary": "Simulation process-exit errors now classify common HuggingFace download/proxy failures into a concise retry/proxy guidance message instead of dumping raw log tails into Step 3.", + "local_refs": [ + "backend/app/services/simulation_runner.py", + "backend/app/i18n.py", + "backend/tests/test_simulation_runner_actions.py" + ], + "validation": [ + "backend/tests/test_simulation_runner_actions.py", + "backend/tests/test_simulation_api_i18n.py", + "backend/tests/test_i18n.py" + ] + }, + "local_status": "covered", + "local_summary": "Simulation process-exit errors now classify common HuggingFace download/proxy failures into a concise retry/proxy guidance message instead of dumping raw log tails into Step 3.", + "triage_status": "covered", + "summary": "Simulation process-exit errors now classify common HuggingFace download/proxy failures into a concise retry/proxy guidance message instead of dumping raw log tails into Step 3.", + "coverage_status": "covered", + "coverage_summary": "Simulation process-exit errors now classify common HuggingFace download/proxy failures into a concise retry/proxy guidance message instead of dumping raw log tails into Step 3.", + "fork_issue_mirrored": true, + "fork_issue_number": 18, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/18" + }, + { + "number": 75, + "title": "Zep 的免费限速会让进度卡99%", + "url": "https://github.com/666ghj/MiroFish/issues/75", + "state": "open", + "created_at": "2026-02-26T08:35:41Z", + "updated_at": "2026-02-28T08:13:33Z", + "closed_at": null, + "labels": [], + "author": "fengkuangyibo", + "body_excerpt": "", + "comment_count": 2, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-02-28T08:09:36Z", + "updated_at": "2026-02-28T08:09:36Z", + "url": "https://github.com/666ghj/MiroFish/issues/75#issuecomment-3976640239", + "body_excerpt": "> No description provided. 现在重试机制好像写的不太行,zep卡了以后就要重跑了,这部分我最近优化一下,可以先试一个小一点的文档,把流程先跑通。稍微大一点的任务充一个zep会员额度够够的,用不完,我觉得还是很划算的。也可以用完以后换一个邮箱,他是按照邮箱送每月额度的。 实在不行邮箱联系我,我自己充了一些额度,给你测试用一下。" + }, + { + "author": "666ghj", + "created_at": "2026-02-28T08:13:21Z", + "updated_at": "2026-02-28T08:13:33Z", + "url": "https://github.com/666ghj/MiroFish/issues/75#issuecomment-3976645572", + "body_excerpt": "现在主页挂了一个在线演示demo,老哥感兴趣可以玩一下:https://666ghj.github.io/mirofish-demo/" + } + ], + "local_coverage": { + "number": 75, + "status": "covered", + "summary": "Graph-builder retries now honor bounded Zep `Retry-After` guidance from `429` responses instead of always using the default backoff schedule, reducing the stuck-at-99% failure mode when Zep free-plan throttling delays graph processing.", + "local_refs": [ + "backend/app/config.py", + "backend/app/services/graph_builder.py", + "backend/tests/test_graph_builder.py" + ], + "validation": [ + "backend/tests/test_graph_builder.py::test_create_graph_respects_retry_after_header", + "backend/tests/test_graph_builder.py::test_create_graph_respects_retry_after_text_hint", + "backend/tests/test_graph_builder.py::test_create_graph_caps_retry_after_delay", + "scripts/test_backend_lite.sh" + ], + "notes": "Mirrored into fork issue tracking on March 11, 2026 after enabling issues on ivanzud/MiroFish." + }, + "local_status": "covered", + "local_summary": "Graph-builder retries now honor bounded Zep `Retry-After` guidance from `429` responses instead of always using the default backoff schedule, reducing the stuck-at-99% failure mode when Zep free-plan throttling delays graph processing.", + "triage_status": "covered", + "summary": "Graph-builder retries now honor bounded Zep `Retry-After` guidance from `429` responses instead of always using the default backoff schedule, reducing the stuck-at-99% failure mode when Zep free-plan throttling delays graph processing.", + "coverage_status": "covered", + "coverage_summary": "Graph-builder retries now honor bounded Zep `Retry-After` guidance from `429` responses instead of always using the default backoff schedule, reducing the stuck-at-99% failure mode when Zep free-plan throttling delays graph processing.", + "fork_issue_mirrored": true, + "fork_issue_number": 19, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/19" + }, + { + "number": 63, + "title": "卡在Debugger PIN: 137-310-617,无法往下走了,是为甚麽", + "url": "https://github.com/666ghj/MiroFish/issues/63", + "state": "closed", + "created_at": "2026-01-28T11:12:29Z", + "updated_at": "2026-02-28T08:12:13Z", + "closed_at": "2026-02-28T08:12:13Z", + "labels": [], + "author": "lxb20251022", + "body_excerpt": "~/MiroFish$ npm run dev > mirofish@0.1.0 dev > concurrently --kill-others -n \"backend,frontend\" -c \"green,cyan\" \"npm run backend\" \"npm run frontend\" [backend] [backend] > mirofish@0.1.0 backend [backend] > cd backend && uv run python run.py [backend] [frontend] [frontend] > mirofish@0.1.0 frontend [frontend] > cd frontend && npm run dev [frontend] [frontend] [frontend] > frontend@0.1.0 dev [front…", + "comment_count": 1, + "recent_comments": [ + { + "author": "tt-a1i", + "created_at": "2026-02-06T08:53:14Z", + "updated_at": "2026-02-06T08:53:14Z", + "url": "https://github.com/666ghj/MiroFish/issues/63#issuecomment-3858876507", + "body_excerpt": "这个不是卡住,应该是 Flask 在 debug 模式下的正常日志,后端启动成功了。 后端进程会一直前台监听请求,所以终端看起来会停在那一行 你直接打开前端 `http://localhost:3000`,或者访问后端健康检查 `http://127.0.0.1:5001/health` 看是否返回 `{\"status\":\"ok\"}`。 直接访问前端页面就可以看到前端ui了" + } + ], + "triage_status": "untracked", + "summary": "~/MiroFish$ npm run dev > mirofish@0.1.0 dev > concurrently --kill-others -n \"backend,frontend\" -c \"green,cyan\" \"npm run backend\" \"npm run frontend\" [backend] [backend] > mirofish@0.1.0 backend [backend] > cd backend && uv run python run.py [backend] [frontend] [frontend] > mirofish@0.1.0 frontend [frontend] > cd frontend && npm run dev [frontend] [frontend] [frontend] > frontend@0.1.0 dev [front…", + "coverage_status": "untracked", + "coverage_summary": "~/MiroFish$ npm run dev > mirofish@0.1.0 dev > concurrently --kill-others -n \"backend,frontend\" -c \"green,cyan\" \"npm run backend\" \"npm run frontend\" [backend] [backend] > mirofish@0.1.0 backend [backend] > cd backend && uv run python run.py [backend] [frontend] [frontend] > mirofish@0.1.0 frontend [frontend] > cd frontend && npm run dev [frontend] [frontend] [frontend] > frontend@0.1.0 dev [front…", + "fork_issue_mirrored": true, + "fork_issue_number": 55, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/55" + }, + { + "number": 76, + "title": "知识图谱构建提供接入 ragflow api 的功能", + "url": "https://github.com/666ghj/MiroFish/issues/76", + "state": "open", + "created_at": "2026-02-27T12:02:39Z", + "updated_at": "2026-02-28T08:02:11Z", + "closed_at": null, + "labels": [ + "Memory Layer" + ], + "author": "Hitomogami", + "body_excerpt": "目前知识图谱的生成依赖于 zep 的api 接口,整个过程较为黑箱,同时免费额度有限,能否考虑接入 ragflow 的 api? ragflow 是一个本地部署很方便的项目,可以通过 api 发起知识图谱创建的请求,也可以手动制作知识图谱后直接通过 api 调用,自定义程度高,且使用的都是通用大模型 api,成本更可控,自定义程度更高。 ragflow 项目地址:https://github.com/infiniflow/ragflow", + "comment_count": 1, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-02-28T08:02:10Z", + "updated_at": "2026-02-28T08:02:10Z", + "url": "https://github.com/666ghj/MiroFish/issues/76#issuecomment-3976629901", + "body_excerpt": "感谢反馈,我近期看一下是否合适" + } + ], + "local_coverage": { + "number": 76, + "status": "tracked", + "summary": "Tracked under beads issue `mirofish-8eg`: RAGflow support remains a backend-abstraction/rebase task and is not safe to land via the stale upstream branch without targeted regression coverage.", + "local_refs": [ + ".beads/issues.jsonl", + "docs/upstream-triage.md" + ], + "validation": [ + "tracking only" + ] + }, + "local_status": "tracked", + "local_summary": "Tracked under beads issue `mirofish-8eg`: RAGflow support remains a backend-abstraction/rebase task and is not safe to land via the stale upstream branch without targeted regression coverage.", + "triage_status": "tracked", + "summary": "Tracked under beads issue `mirofish-8eg`: RAGflow support remains a backend-abstraction/rebase task and is not safe to land via the stale upstream branch without targeted regression coverage.", + "coverage_status": "tracked", + "coverage_summary": "Tracked under beads issue `mirofish-8eg`: RAGflow support remains a backend-abstraction/rebase task and is not safe to land via the stale upstream branch without targeted regression coverage.", + "fork_issue_mirrored": true, + "fork_issue_number": 20, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/20" + }, + { + "number": 71, + "title": "输入文件选择多个时,前端没有正确预留空间", + "url": "https://github.com/666ghj/MiroFish/issues/71", + "state": "closed", + "created_at": "2026-02-14T16:16:31Z", + "updated_at": "2026-02-22T15:41:08Z", + "closed_at": "2026-02-22T15:41:08Z", + "labels": [], + "author": "MoeclubM", + "body_excerpt": "如图,文件超出五个就有问题了 <img width=\"1394\" height=\"1152\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/2691a7f2-a6f4-4877-a228-dc05c912a73c\" />", + "comment_count": 2, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-02-16T20:16:33Z", + "updated_at": "2026-02-16T20:16:33Z", + "url": "https://github.com/666ghj/MiroFish/issues/71#issuecomment-3910365506", + "body_excerpt": "感谢反馈,马上修复" + }, + { + "author": "666ghj", + "created_at": "2026-02-22T15:41:06Z", + "updated_at": "2026-02-22T15:41:06Z", + "url": "https://github.com/666ghj/MiroFish/issues/71#issuecomment-3941233636", + "body_excerpt": "已经修复" + } + ], + "triage_status": "untracked", + "summary": "如图,文件超出五个就有问题了 <img width=\"1394\" height=\"1152\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/2691a7f2-a6f4-4877-a228-dc05c912a73c\" />", + "coverage_status": "untracked", + "coverage_summary": "如图,文件超出五个就有问题了 <img width=\"1394\" height=\"1152\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/2691a7f2-a6f4-4877-a228-dc05c912a73c\" />", + "fork_issue_mirrored": true, + "fork_issue_number": 56, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/56" + }, + { + "number": 59, + "title": "上传文件时出现Exception in handleNewProject: Network Error报错", + "url": "https://github.com/666ghj/MiroFish/issues/59", + "state": "closed", + "created_at": "2026-01-24T14:25:43Z", + "updated_at": "2026-02-22T09:42:07Z", + "closed_at": "2026-02-22T09:42:07Z", + "labels": [], + "author": "HenryZhou2008", + "body_excerpt": "使用docker在局域网内服务器中部署 前端上传文件时出现Exception in handleNewProject: Network Error报错 查询#57 后,尝试pdf/txt/md文件格式重新上传,均出现相同报错 由于docker部署,log文件无法提取,无法定位问题 希望各位大佬可以帮忙解决一下问题", + "comment_count": 6, + "recent_comments": [ + { + "author": "cyx728", + "created_at": "2026-01-24T15:50:57Z", + "updated_at": "2026-01-24T15:54:59Z", + "url": "https://github.com/666ghj/MiroFish/issues/59#issuecomment-3794848125", + "body_excerpt": "我也遇到了同样的问题。应该是后端没有成功启动,尝试手动开启后端时发现依赖有问题,最后把3.14的python卸了换3.11重新安装所有依赖后能启动后端了。但我不确定您使用docker部署的话这个方案有没有帮助" + }, + { + "author": "skoa323", + "created_at": "2026-01-27T16:59:45Z", + "updated_at": "2026-01-27T16:59:45Z", + "url": "https://github.com/666ghj/MiroFish/issues/59#issuecomment-3806364083", + "body_excerpt": "这个文件里面把后端地址强制指定到本地了,所以服务器一直请求不到 `src/api/index.js` 进去把地址修改成服务器的公网ip就行 nano src/api/index.js" + }, + { + "author": "xy200303", + "created_at": "2026-02-01T13:56:23Z", + "updated_at": "2026-02-01T13:56:23Z", + "url": "https://github.com/666ghj/MiroFish/issues/59#issuecomment-3831091168", + "body_excerpt": "<img width=\"1861\" height=\"850\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/74e99fc5-f84a-445e-af65-3ea38102c14b\" />" + } + ], + "triage_status": "untracked", + "summary": "使用docker在局域网内服务器中部署 前端上传文件时出现Exception in handleNewProject: Network Error报错 查询#57 后,尝试pdf/txt/md文件格式重新上传,均出现相同报错 由于docker部署,log文件无法提取,无法定位问题 希望各位大佬可以帮忙解决一下问题", + "coverage_status": "untracked", + "coverage_summary": "使用docker在局域网内服务器中部署 前端上传文件时出现Exception in handleNewProject: Network Error报错 查询#57 后,尝试pdf/txt/md文件格式重新上传,均出现相同报错 由于docker部署,log文件无法提取,无法定位问题 希望各位大佬可以帮忙解决一下问题", + "fork_issue_mirrored": true, + "fork_issue_number": 57, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/57" + }, + { + "number": 9, + "title": "可以设置中断重新加载吗", + "url": "https://github.com/666ghj/MiroFish/issues/9", + "state": "open", + "created_at": "2025-12-28T13:16:54Z", + "updated_at": "2026-02-21T09:55:20Z", + "closed_at": null, + "labels": [], + "author": "LMG-arch", + "body_excerpt": "希望可以中断重新加载,有时候调用大模型会出错就要重新开始了,", + "comment_count": 4, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2025-12-31T03:40:35Z", + "updated_at": "2025-12-31T03:40:35Z", + "url": "https://github.com/666ghj/MiroFish/issues/9#issuecomment-3701372628", + "body_excerpt": "> 希望可以中断重新加载,有时候调用大模型会出错就要重新开始了, 好,可以具体说一下场景吗,是在模拟过程中还是前面生成配置的过程中,或者是后面report的环节?因为我记得运行时加了重试机制的嘞。" + }, + { + "author": "LMG-arch", + "created_at": "2025-12-31T03:43:45Z", + "updated_at": "2025-12-31T03:43:45Z", + "url": "https://github.com/666ghj/MiroFish/issues/9#issuecomment-3701377365", + "body_excerpt": "> > 希望可以中断重新加载,有时候调用大模型会出错就要重新开始了, > > 好,可以具体说一下场景吗,是在模拟过程中还是前面生成配置的过程中,或者是后面report的环节?因为我记得运行时加了重试机制的嘞。 在模拟过程中,我使用阿里百炼的时候免费额度用完以后他就断了,重新配置启动以后就要重新模拟了" + }, + { + "author": "666ghj", + "created_at": "2025-12-31T19:33:31Z", + "updated_at": "2025-12-31T19:33:31Z", + "url": "https://github.com/666ghj/MiroFish/issues/9#issuecomment-3702763655", + "body_excerpt": "> > > 希望可以中断重新加载,有时候调用大模型会出错就要重新开始了, > > > > > > 好,可以具体说一下场景吗,是在模拟过程中还是前面生成配置的过程中,或者是后面report的环节?因为我记得运行时加了重试机制的嘞。 > > 在模拟过程中,我使用阿里百炼的时候免费额度用完以后他就断了,重新配置启动以后就要重新模拟了 模拟过程中这个目前没有办法,因为直接使用的是第三方库,开源为了大家部署方便没有使用源码部署,后续应该会开个分支,把专业开发版分出来就可以更改了" + } + ], + "local_coverage": { + "number": 9, + "status": "partial", + "summary": "Refresh/navigation is safer locally because Step 3 reattaches to existing simulation state instead of force-restarting, the history modal exposes a replay-only Step 3 route that loads an existing timeline without accidentally auto-starting a fresh run, the Step 3 panel now makes the same-simulation restart path explicit after quota/API-key failures, Step 2 now surfaces a direct recovery card that reopens the saved Step 3 replay/restart route without making users hunt through history first, and Step 5 now exposes that same direct Step 3 recovery route whenever the interview environment is offline but the prepared simulation still has replayable state. True mid-run checkpoint/resume is still deferred.", + "local_refs": [ + "frontend/src/components/Step2EnvSetup.vue", + "frontend/src/components/Step5Interaction.vue", + "frontend/src/components/step2Recovery.js", + "frontend/src/components/step5Recovery.js", + "frontend/src/components/HistoryDatabase.vue", + "frontend/src/views/SimulationRunView.vue", + "frontend/src/views/SimulationView.vue", + "frontend/src/components/Step3Simulation.vue", + "frontend/src/components/historyPlayback.js", + "frontend/src/components/simulationReplay.js", + "frontend/tests/historyPlayback.test.mjs", + "frontend/tests/step2Recovery.test.mjs", + "frontend/tests/step5Recovery.test.mjs" + ], + "validation": [ + "frontend: npm test", + "frontend: npm run build" + ], + "notes": "The repo still cannot checkpoint and continue from the exact failure point after a provider outage, but the prepared-environment recovery path is now available from history, the Step 2 screen, and the Step 5 offline-interview workspace." + }, + "local_status": "partial", + "local_summary": "Refresh/navigation is safer locally because Step 3 reattaches to existing simulation state instead of force-restarting, the history modal exposes a replay-only Step 3 route that loads an existing timeline without accidentally auto-starting a fresh run, the Step 3 panel now makes the same-simulation restart path explicit after quota/API-key failures, Step 2 now surfaces a direct recovery card that reopens the saved Step 3 replay/restart route without making users hunt through history first, and Step 5 now exposes that same direct Step 3 recovery route whenever the interview environment is offline but the prepared simulation still has replayable state. True mid-run checkpoint/resume is still deferred.", + "triage_status": "partial", + "summary": "Refresh/navigation is safer locally because Step 3 reattaches to existing simulation state instead of force-restarting, the history modal exposes a replay-only Step 3 route that loads an existing timeline without accidentally auto-starting a fresh run, the Step 3 panel now makes the same-simulation restart path explicit after quota/API-key failures, Step 2 now surfaces a direct recovery card that reopens the saved Step 3 replay/restart route without making users hunt through history first, and Step 5 now exposes that same direct Step 3 recovery route whenever the interview environment is offline but the prepared simulation still has replayable state. True mid-run checkpoint/resume is still deferred.", + "coverage_status": "partial", + "coverage_summary": "Refresh/navigation is safer locally because Step 3 reattaches to existing simulation state instead of force-restarting, the history modal exposes a replay-only Step 3 route that loads an existing timeline without accidentally auto-starting a fresh run, the Step 3 panel now makes the same-simulation restart path explicit after quota/API-key failures, Step 2 now surfaces a direct recovery card that reopens the saved Step 3 replay/restart route without making users hunt through history first, and Step 5 now exposes that same direct Step 3 recovery route whenever the interview environment is offline but the prepared simulation still has replayable state. True mid-run checkpoint/resume is still deferred.", + "fork_issue_mirrored": true, + "fork_issue_number": 21, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/21" + }, + { + "number": 67, + "title": "生成内容过于离谱", + "url": "https://github.com/666ghj/MiroFish/issues/67", + "state": "closed", + "created_at": "2026-02-09T07:08:21Z", + "updated_at": "2026-02-10T08:43:01Z", + "closed_at": "2026-02-10T08:43:01Z", + "labels": [], + "author": "Charlo-O", + "body_excerpt": "我输入的是一篇普通的古风言情小书 门第、财富与婚约的未来博弈:模拟世界中的阶层再缔结预测报告 在‘婚配即联盟’的结构性逻辑下,未来社会演化出以家族财富竞标为表、代际意志博弈为里、女性主体性在契约缝隙中悄然重构的新型门阀动态。 01 婚约作为资源再分配协议 婚约作为资源再分配协议,在模拟世界中已演化为具备真实调度力、可验证执行性与跨阶层渗透力的社会操作系统。其核心特征并非抽象契约精神,而是嵌入国家验证网络(NMPVN)、联动产业数据库、响应能力认证信号、并受保险机制兜底的闭环基础设施。 协议触发即资源位移,无须意志确认 当林氏女的技术认证通过工信部系统自动回传至协律AI平台,其名下“云枢数据”3.1%表决权在0.8秒内完成工商登记系统预校验与董事会席位映射;当杭州茶商家族茶园传感器上传pH值连续7日达标,品质溢价信托即按预设公式生成分红指令——所有资源流动均不依赖人工审批、不触发协商程序、…", + "comment_count": 1, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-02-09T07:44:12Z", + "updated_at": "2026-02-09T07:44:12Z", + "url": "https://github.com/666ghj/MiroFish/issues/67#issuecomment-3869889329", + "body_excerpt": "🤣" + } + ], + "triage_status": "untracked", + "summary": "我输入的是一篇普通的古风言情小书 门第、财富与婚约的未来博弈:模拟世界中的阶层再缔结预测报告 在‘婚配即联盟’的结构性逻辑下,未来社会演化出以家族财富竞标为表、代际意志博弈为里、女性主体性在契约缝隙中悄然重构的新型门阀动态。 01 婚约作为资源再分配协议 婚约作为资源再分配协议,在模拟世界中已演化为具备真实调度力、可验证执行性与跨阶层渗透力的社会操作系统。其核心特征并非抽象契约精神,而是嵌入国家验证网络(NMPVN)、联动产业数据库、响应能力认证信号、并受保险机制兜底的闭环基础设施。 协议触发即资源位移,无须意志确认 当林氏女的技术认证通过工信部系统自动回传至协律AI平台,其名下“云枢数据”3.1%表决权在0.8秒内完成工商登记系统预校验与董事会席位映射;当杭州茶商家族茶园传感器上传pH值连续7日达标,品质溢价信托即按预设公式生成分红指令——所有资源流动均不依赖人工审批、不触发协商程序、…", + "coverage_status": "untracked", + "coverage_summary": "我输入的是一篇普通的古风言情小书 门第、财富与婚约的未来博弈:模拟世界中的阶层再缔结预测报告 在‘婚配即联盟’的结构性逻辑下,未来社会演化出以家族财富竞标为表、代际意志博弈为里、女性主体性在契约缝隙中悄然重构的新型门阀动态。 01 婚约作为资源再分配协议 婚约作为资源再分配协议,在模拟世界中已演化为具备真实调度力、可验证执行性与跨阶层渗透力的社会操作系统。其核心特征并非抽象契约精神,而是嵌入国家验证网络(NMPVN)、联动产业数据库、响应能力认证信号、并受保险机制兜底的闭环基础设施。 协议触发即资源位移,无须意志确认 当林氏女的技术认证通过工信部系统自动回传至协律AI平台,其名下“云枢数据”3.1%表决权在0.8秒内完成工商登记系统预校验与董事会席位映射;当杭州茶商家族茶园传感器上传pH值连续7日达标,品质溢价信托即按预设公式生成分红指令——所有资源流动均不依赖人工审批、不触发协商程序、…", + "fork_issue_mirrored": true, + "fork_issue_number": 58, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/58" + }, + { + "number": 69, + "title": "模型对接", + "url": "https://github.com/666ghj/MiroFish/issues/69", + "state": "open", + "created_at": "2026-02-10T07:53:43Z", + "updated_at": "2026-02-10T07:56:20Z", + "closed_at": null, + "labels": [ + "question" + ], + "author": "wjh-w", + "body_excerpt": "我们使用mirofish 的时候可以对接 哪些模型可以帮忙解答一下,比如 # ===== ZEP记忆图谱配置 ===== # 每月免费额度即可支撑简单使用:https://app.getzep.com/ 这个额度用完了还可以使用哪个模型接口,或者说适配哪些模型接口 等等一系列的 都有哪些呢?", + "comment_count": 1, + "recent_comments": [ + { + "author": "dosubot[bot]", + "created_at": "2026-02-10T07:56:20Z", + "updated_at": "2026-02-10T07:56:20Z", + "url": "https://github.com/666ghj/MiroFish/issues/69#issuecomment-3875973660", + "body_excerpt": "<!-- Answer --> MiroFish 支持对接任何兼容 OpenAI SDK 格式的 LLM API,比如 OpenAI、Azure OpenAI、阿里云百炼(qwen-plus)、以及本地自部署的 OpenAI 兼容模型等。你只需要在 .env 配置文件中填写对应的 LLM_API_KEY、LLM_BASE_URL 和 LLM_MODEL_NAME,就可以切换不同的模型接口。例如官方推荐的配置是阿里云百炼的 qwen-plus 模型,也可以直接用 OpenAI…" + } + ], + "local_coverage": { + "number": 69, + "status": "covered", + "summary": "Both READMEs now explicitly document direct OpenAI-compatible usage through either `LLM_*` or `OPENAI_*` environment variables, with examples for Codex/OpenAI-compatible gateways, DashScope, LM Studio, and Ollama.", + "local_refs": [ + "README.md", + "README-EN.md", + ".env.example" + ], + "validation": [ + "documentation review" + ] + }, + "local_status": "covered", + "local_summary": "Both READMEs now explicitly document direct OpenAI-compatible usage through either `LLM_*` or `OPENAI_*` environment variables, with examples for Codex/OpenAI-compatible gateways, DashScope, LM Studio, and Ollama.", + "triage_status": "covered", + "summary": "Both READMEs now explicitly document direct OpenAI-compatible usage through either `LLM_*` or `OPENAI_*` environment variables, with examples for Codex/OpenAI-compatible gateways, DashScope, LM Studio, and Ollama.", + "coverage_status": "covered", + "coverage_summary": "Both READMEs now explicitly document direct OpenAI-compatible usage through either `LLM_*` or `OPENAI_*` environment variables, with examples for Codex/OpenAI-compatible gateways, DashScope, LM Studio, and Ollama.", + "fork_issue_mirrored": true, + "fork_issue_number": 22, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/22" + }, + { + "number": 37, + "title": "模拟环境未运行或已关闭,无法执行Interview: sim_66f01bcf8013。模拟环境可能已关闭,请确保OASIS环境正在运行。", + "url": "https://github.com/666ghj/MiroFish/issues/37", + "state": "open", + "created_at": "2026-01-20T09:10:49Z", + "updated_at": "2026-02-09T04:54:14Z", + "closed_at": null, + "labels": [], + "author": "Ezj-Amon", + "body_excerpt": "这个报错不知道是为啥", + "comment_count": 1, + "recent_comments": [ + { + "author": "maicent", + "created_at": "2026-02-09T04:54:14Z", + "updated_at": "2026-02-09T04:54:14Z", + "url": "https://github.com/666ghj/MiroFish/issues/37#issuecomment-3869291906", + "body_excerpt": "我也遇到了相同问题,请问如何解决" + } + ], + "local_coverage": { + "number": 37, + "status": "covered", + "summary": "Step 5 now preflights simulation environment status and blocks interview requests against closed or unavailable backends before they fail.", + "local_refs": [ + "frontend/src/components/Step5Interaction.vue", + "frontend/src/api/simulation.js" + ], + "validation": [ + "frontend: npm test", + "frontend: npm run build" + ] + }, + "local_status": "covered", + "local_summary": "Step 5 now preflights simulation environment status and blocks interview requests against closed or unavailable backends before they fail.", + "triage_status": "covered", + "summary": "Step 5 now preflights simulation environment status and blocks interview requests against closed or unavailable backends before they fail.", + "coverage_status": "covered", + "coverage_summary": "Step 5 now preflights simulation environment status and blocks interview requests against closed or unavailable backends before they fail.", + "fork_issue_mirrored": true, + "fork_issue_number": 23, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/23" + }, + { + "number": 66, + "title": "使用docker 运行之后 启动引擎一直处于灰色无法点击状态", + "url": "https://github.com/666ghj/MiroFish/issues/66", + "state": "closed", + "created_at": "2026-02-03T02:35:42Z", + "updated_at": "2026-02-03T02:41:27Z", + "closed_at": "2026-02-03T02:41:27Z", + "labels": [], + "author": "wjh-w", + "body_excerpt": "<img width=\"1769\" height=\"754\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/1d4ce274-c965-472b-af57-4ccf788ebb1d\" /> <img width=\"1339\" height=\"610\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/94ad9afa-bcee-4b45-b8ad-c135aeb0269a\" />", + "comment_count": 0, + "recent_comments": [], + "triage_status": "untracked", + "summary": "<img width=\"1769\" height=\"754\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/1d4ce274-c965-472b-af57-4ccf788ebb1d\" /> <img width=\"1339\" height=\"610\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/94ad9afa-bcee-4b45-b8ad-c135aeb0269a\" />", + "coverage_status": "untracked", + "coverage_summary": "<img width=\"1769\" height=\"754\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/1d4ce274-c965-472b-af57-4ccf788ebb1d\" /> <img width=\"1339\" height=\"610\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/94ad9afa-bcee-4b45-b8ad-c135aeb0269a\" />", + "fork_issue_mirrored": true, + "fork_issue_number": 59, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/59" + }, + { + "number": 65, + "title": "docker 运行之后 页面一直 启动引擎一直处于无法点击的状态", + "url": "https://github.com/666ghj/MiroFish/issues/65", + "state": "closed", + "created_at": "2026-02-03T02:34:31Z", + "updated_at": "2026-02-03T02:34:54Z", + "closed_at": "2026-02-03T02:34:54Z", + "labels": [], + "author": "wjh-w", + "body_excerpt": "<img width=\"1769\" height=\"754\" alt=\"Image\" src=\"https://github.com/user-attachmen <img width=\"1339\" height=\"610\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/4ede167d-dba9-4582-966b-a33fb22ce8f0\" /> ts/assets/505a429a-ad7b-4568-a8e5-da0547d7bd11\" />", + "comment_count": 0, + "recent_comments": [], + "triage_status": "untracked", + "summary": "<img width=\"1769\" height=\"754\" alt=\"Image\" src=\"https://github.com/user-attachmen <img width=\"1339\" height=\"610\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/4ede167d-dba9-4582-966b-a33fb22ce8f0\" /> ts/assets/505a429a-ad7b-4568-a8e5-da0547d7bd11\" />", + "coverage_status": "untracked", + "coverage_summary": "<img width=\"1769\" height=\"754\" alt=\"Image\" src=\"https://github.com/user-attachmen <img width=\"1339\" height=\"610\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/4ede167d-dba9-4582-966b-a33fb22ce8f0\" /> ts/assets/505a429a-ad7b-4568-a8e5-da0547d7bd11\" />", + "fork_issue_mirrored": true, + "fork_issue_number": 60, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/60" + }, + { + "number": 60, + "title": "Zep 免费计划 429 限流导致图谱构建失败(请求过密)", + "url": "https://github.com/666ghj/MiroFish/issues/60", + "state": "open", + "created_at": "2026-01-24T16:03:41Z", + "updated_at": "2026-01-28T08:36:57Z", + "closed_at": null, + "labels": [], + "author": "Jonah-Wu23", + "body_excerpt": "问题反馈:图谱构建时遇到 Zep 429 限流 现象: 在构建图谱时(调用 Zep Graph API),后台出现 429: [backend] [23:36:22] ERROR: [e1379cb6-ee6b-4989-810b-63ef2e07fa84] 图谱构建失败: headers: {'date': 'Sat, 24 Jan 2026 15:36:21 GMT', 'content-type': 'text/plain; charset=utf-8', 'content-length': '34', 'connection': 'keep-alive', 'cf-ray': '9c309b1a5c42db20-MNL', 'retry-after': '60', 'vary': 'Origin', 'x-content-type-options': 'nosniff', 'x-…", + "comment_count": 4, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-01-24T16:17:07Z", + "updated_at": "2026-01-24T16:17:07Z", + "url": "https://github.com/666ghj/MiroFish/issues/60#issuecomment-3794956637", + "body_excerpt": "感谢反馈" + }, + { + "author": "jstdoit", + "created_at": "2026-01-27T15:11:45Z", + "updated_at": "2026-01-27T15:11:45Z", + "url": "https://github.com/666ghj/MiroFish/issues/60#issuecomment-3805766080", + "body_excerpt": "这个还挺头疼的,导致demo可以,实用上稍微不理想" + }, + { + "author": "magicnight", + "created_at": "2026-01-27T17:34:00Z", + "updated_at": "2026-01-27T17:34:00Z", + "url": "https://github.com/666ghj/MiroFish/issues/60#issuecomment-3806536285", + "body_excerpt": "免费版本只有1000个额度,基本跑一两个实验性的就没了。充值吧,要么考虑自己本地搞。" + } + ], + "local_coverage": { + "number": 60, + "status": "covered", + "summary": "Graph-builder retries now honor bounded Zep `Retry-After` guidance from `429` responses instead of always using the default backoff schedule, reducing premature failures when Zep throttles graph creation or uploads.", + "local_refs": [ + "backend/app/config.py", + "backend/app/services/graph_builder.py", + "backend/tests/test_graph_builder.py" + ], + "validation": [ + "backend/tests/test_graph_builder.py::test_create_graph_respects_retry_after_header", + "backend/tests/test_graph_builder.py::test_create_graph_respects_retry_after_text_hint", + "backend/tests/test_graph_builder.py::test_create_graph_caps_retry_after_delay", + "scripts/test_backend_lite.sh" + ], + "notes": "Mirrored into fork issue tracking on March 11, 2026 after enabling issues on ivanzud/MiroFish." + }, + "local_status": "covered", + "local_summary": "Graph-builder retries now honor bounded Zep `Retry-After` guidance from `429` responses instead of always using the default backoff schedule, reducing premature failures when Zep throttles graph creation or uploads.", + "triage_status": "covered", + "summary": "Graph-builder retries now honor bounded Zep `Retry-After` guidance from `429` responses instead of always using the default backoff schedule, reducing premature failures when Zep throttles graph creation or uploads.", + "coverage_status": "covered", + "coverage_summary": "Graph-builder retries now honor bounded Zep `Retry-After` guidance from `429` responses instead of always using the default backoff schedule, reducing premature failures when Zep throttles graph creation or uploads.", + "fork_issue_mirrored": true, + "fork_issue_number": 24, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/24" + }, + { + "number": 61, + "title": "能否将修改api的功能在前端也实现", + "url": "https://github.com/666ghj/MiroFish/issues/61", + "state": "open", + "created_at": "2026-01-27T13:37:15Z", + "updated_at": "2026-01-28T08:35:08Z", + "closed_at": null, + "labels": [], + "author": "skoa323", + "body_excerpt": "", + "comment_count": 1, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-01-28T08:35:08Z", + "updated_at": "2026-01-28T08:35:08Z", + "url": "https://github.com/666ghj/MiroFish/issues/61#issuecomment-3809795302", + "body_excerpt": "可以详细描述一下吗,不是很懂" + } + ], + "local_coverage": { + "number": 61, + "status": "covered", + "summary": "The frontend now exposes a persisted runtime backend API override panel on the home screen and Step 1 / Step 2 workbench, so users can repoint the UI to another backend without rebuilding.", + "local_refs": [ + "frontend/src/api/baseUrl.js", + "frontend/src/api/index.js", + "frontend/src/components/ApiEndpointControl.vue", + "frontend/src/views/Home.vue", + "frontend/src/views/MainView.vue", + "README.md", + "README-EN.md" + ], + "validation": [ + "frontend/tests/baseUrl.test.mjs", + "frontend: npm run build" + ] + }, + "local_status": "covered", + "local_summary": "The frontend now exposes a persisted runtime backend API override panel on the home screen and Step 1 / Step 2 workbench, so users can repoint the UI to another backend without rebuilding.", + "triage_status": "covered", + "summary": "The frontend now exposes a persisted runtime backend API override panel on the home screen and Step 1 / Step 2 workbench, so users can repoint the UI to another backend without rebuilding.", + "coverage_status": "covered", + "coverage_summary": "The frontend now exposes a persisted runtime backend API override panel on the home screen and Step 1 / Step 2 workbench, so users can repoint the UI to another backend without rebuilding.", + "fork_issue_mirrored": true, + "fork_issue_number": 25, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/25" + }, + { + "number": 62, + "title": "很好的创意,专门注册来提提问题和建议啦", + "url": "https://github.com/666ghj/MiroFish/issues/62", + "state": "open", + "created_at": "2026-01-28T01:39:38Z", + "updated_at": "2026-01-28T08:34:36Z", + "closed_at": null, + "labels": [], + "author": "piaopiaomiaomiao", + "body_excerpt": "1,我也是ollama本地部署,用的是qwen3 A3B那个模型,图谱用的zep,下一步也考虑本地,如果有整合本地包就更好了~~ 2,使用了三天了,跑了4个项目,都不大,zep一共用了200多,小规模实验用还好。 3,4个小项目的共性问题是形成结果后报告助手沟通没问题,但是单人采访不行,报错504. 4,建议自定义轮数可以一开始就填入,给一个一键运行到底的按钮,这样提交完现实种子,选好轮数,点一下就可以静候佳音了,现在中间还要确定两次。 5,up加油!!", + "comment_count": 1, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-01-28T08:34:36Z", + "updated_at": "2026-01-28T08:34:36Z", + "url": "https://github.com/666ghj/MiroFish/issues/62#issuecomment-3809793307", + "body_excerpt": "> 1,我也是ollama本地部署,用的是qwen3 A3B那个模型,图谱用的zep,下一步也考虑本地,如果有整合本地包就更好了~~ 2,使用了三天了,跑了4个项目,都不大,zep一共用了200多,小规模实验用还好。 3,4个小项目的共性问题是形成结果后报告助手沟通没问题,但是单人采访不行,报错504. 4,建议自定义轮数可以一开始就填入,给一个一键运行到底的按钮,这样提交完现实种子,选好轮数,点一下就可以静候佳音了,现在中间还要确定两次。 5,up加油!! 第三个采访,i…" + } + ], + "local_coverage": { + "number": 62, + "status": "partial", + "summary": "The Step 5 timeout complaint now has explicit local mitigations across both UI-driven and report-agent-driven interviews: frontend Step 5 requests derive adaptive timeout budgets from the request window, the UI shows the effective single-agent and current survey-batch budget, the docs/env template expose timeout knobs for slower local models, and backend/app/services/zep_tools.py now honors INTERVIEW_BATCH_TIMEOUT_SECONDS instead of forcing a fixed 180-second timeout for live report-agent batch interviews. The broader issue's remaining workflow requests are now tracked separately in beads as `mirofish-as6`.", + "local_refs": [ + ".beads/issues.jsonl", + "frontend/src/api/timeout.js", + "frontend/src/components/Step5Interaction.vue", + "frontend/src/components/step5Profiles.js", + "backend/app/services/zep_tools.py", + "backend/tests/test_zep_tools_i18n.py", + ".env.example", + "README.md", + "README-EN.md" + ], + "validation": [ + "frontend: npm test", + "frontend: npm run build", + "backend: uv run pytest -q tests/test_zep_tools_i18n.py" + ] + }, + "local_status": "partial", + "local_summary": "The Step 5 timeout complaint now has explicit local mitigations across both UI-driven and report-agent-driven interviews: frontend Step 5 requests derive adaptive timeout budgets from the request window, the UI shows the effective single-agent and current survey-batch budget, the docs/env template expose timeout knobs for slower local models, and backend/app/services/zep_tools.py now honors INTERVIEW_BATCH_TIMEOUT_SECONDS instead of forcing a fixed 180-second timeout for live report-agent batch interviews. The broader issue's remaining workflow requests are now tracked separately in beads as `mirofish-as6`.", + "triage_status": "partial", + "summary": "The Step 5 timeout complaint now has explicit local mitigations across both UI-driven and report-agent-driven interviews: frontend Step 5 requests derive adaptive timeout budgets from the request window, the UI shows the effective single-agent and current survey-batch budget, the docs/env template expose timeout knobs for slower local models, and backend/app/services/zep_tools.py now honors INTERVIEW_BATCH_TIMEOUT_SECONDS instead of forcing a fixed 180-second timeout for live report-agent batch interviews. The broader issue's remaining workflow requests are now tracked separately in beads as `mirofish-as6`.", + "coverage_status": "partial", + "coverage_summary": "The Step 5 timeout complaint now has explicit local mitigations across both UI-driven and report-agent-driven interviews: frontend Step 5 requests derive adaptive timeout budgets from the request window, the UI shows the effective single-agent and current survey-batch budget, the docs/env template expose timeout knobs for slower local models, and backend/app/services/zep_tools.py now honors INTERVIEW_BATCH_TIMEOUT_SECONDS instead of forcing a fixed 180-second timeout for live report-agent batch interviews. The broader issue's remaining workflow requests are now tracked separately in beads as `mirofish-as6`.", + "fork_issue_mirrored": true, + "fork_issue_number": 26, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/26" + }, + { + "number": 58, + "title": "使用ollama加载的本地大模型启动引擎时经常会遇到超时问题", + "url": "https://github.com/666ghj/MiroFish/issues/58", + "state": "open", + "created_at": "2026-01-24T10:47:13Z", + "updated_at": "2026-01-24T16:18:14Z", + "closed_at": null, + "labels": [], + "author": "ThomasWang071001", + "body_excerpt": "如果本地大模型响应时间太长(超过300000ms)则会出现error并且即使后台监控大模型完成了输出前端也无法正常显示 有没有办法把这个超时限制关掉", + "comment_count": 3, + "recent_comments": [ + { + "author": "ThomasWang071001", + "created_at": "2026-01-24T12:30:18Z", + "updated_at": "2026-01-24T12:30:18Z", + "url": "https://github.com/666ghj/MiroFish/issues/58#issuecomment-3794560081", + "body_excerpt": "backend会不断试图进行本体生成 [backend] * Serving Flask app 'app' [backend] * Debug mode: on [backend] WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead. [backend] * Running on all a…" + }, + { + "author": "puji4810", + "created_at": "2026-01-24T12:38:17Z", + "updated_at": "2026-01-24T12:38:17Z", + "url": "https://github.com/666ghj/MiroFish/issues/58#issuecomment-3794571191", + "body_excerpt": "源码部署自己修改一下咯。本地ai又慢又弱,不推荐。你这响应时间超过300000ms了还是用api吧" + }, + { + "author": "666ghj", + "created_at": "2026-01-24T16:18:13Z", + "updated_at": "2026-01-24T16:18:13Z", + "url": "https://github.com/666ghj/MiroFish/issues/58#issuecomment-3794961360", + "body_excerpt": "这个让ai帮忙定位一下,一个超时参数的数值问题" + } + ], + "local_coverage": { + "number": 58, + "status": "covered", + "summary": "Step 5 interviews now derive timeout overrides from the configured frontend request window, and the backend/docs expose dedicated interview timeout settings for slower local models.", + "local_refs": [ + "frontend/src/api/timeout.js", + "frontend/src/components/Step5Interaction.vue", + ".env.example" + ], + "validation": [ + "frontend: npm test", + "frontend: npm run build" + ] + }, + "local_status": "covered", + "local_summary": "Step 5 interviews now derive timeout overrides from the configured frontend request window, and the backend/docs expose dedicated interview timeout settings for slower local models.", + "triage_status": "covered", + "summary": "Step 5 interviews now derive timeout overrides from the configured frontend request window, and the backend/docs expose dedicated interview timeout settings for slower local models.", + "coverage_status": "covered", + "coverage_summary": "Step 5 interviews now derive timeout overrides from the configured frontend request window, and the backend/docs expose dedicated interview timeout settings for slower local models.", + "fork_issue_mirrored": true, + "fork_issue_number": 27, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/27" + }, + { + "number": 57, + "title": "Exception in handleNewProject: Network Error", + "url": "https://github.com/666ghj/MiroFish/issues/57", + "state": "closed", + "created_at": "2026-01-24T03:21:30Z", + "updated_at": "2026-01-24T03:28:54Z", + "closed_at": "2026-01-24T03:28:54Z", + "labels": [], + "author": "ham0nd", + "body_excerpt": "我使用docker或者npm部署环境,都显示Exception in handleNewProject: Network Error,我上传的文件是utf-8编码的txt,是我哪里有问题吗 <img width=\"994\" height=\"919\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/cfb06bfd-0657-4940-9406-85e975941c09\" />", + "comment_count": 2, + "recent_comments": [ + { + "author": "ham0nd", + "created_at": "2026-01-24T03:23:17Z", + "updated_at": "2026-01-24T03:23:17Z", + "url": "https://github.com/666ghj/MiroFish/issues/57#issuecomment-3793655623", + "body_excerpt": "我使用的环境是python3.12 ~/MiroFish$ npm run dev > mirofish@0.1.0 dev > concurrently --kill-others -n \"backend,frontend\" -c \"green,cyan\" \"npm run backend\" \"npm run frontend\" [backend] [backend] > mirofish@0.1.0 backend [backend] > cd backend && u…" + }, + { + "author": "ham0nd", + "created_at": "2026-01-24T03:28:54Z", + "updated_at": "2026-01-24T03:28:54Z", + "url": "https://github.com/666ghj/MiroFish/issues/57#issuecomment-3793667034", + "body_excerpt": "没事了,经排查发现是前端访问了localhost,后端是http://192.168.43.132:5001,手动修改后成功" + } + ], + "triage_status": "untracked", + "summary": "我使用docker或者npm部署环境,都显示Exception in handleNewProject: Network Error,我上传的文件是utf-8编码的txt,是我哪里有问题吗 <img width=\"994\" height=\"919\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/cfb06bfd-0657-4940-9406-85e975941c09\" />", + "coverage_status": "untracked", + "coverage_summary": "我使用docker或者npm部署环境,都显示Exception in handleNewProject: Network Error,我上传的文件是utf-8编码的txt,是我哪里有问题吗 <img width=\"994\" height=\"919\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/cfb06bfd-0657-4940-9406-85e975941c09\" />", + "fork_issue_mirrored": true, + "fork_issue_number": 61, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/61" + }, + { + "number": 46, + "title": "npm安装依赖和配置提示project.license` as a TOML table is deprecated", + "url": "https://github.com/666ghj/MiroFish/issues/46", + "state": "open", + "created_at": "2026-01-21T12:26:05Z", + "updated_at": "2026-01-23T10:34:52Z", + "closed_at": null, + "labels": [], + "author": "Vamco2022", + "body_excerpt": "npm run setup:all提示 Please use a simple string containing a SPDX expression for `project.license`. You can also use `project.license-files`. (Both options available on setuptools>=77.0.0). By 2026-Feb-18, you need to update your project and remove deprecated calls or your builds will no longer be supported. See https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#license for deta…", + "comment_count": 2, + "recent_comments": [ + { + "author": "wjcwqc", + "created_at": "2026-01-22T09:09:47Z", + "updated_at": "2026-01-22T09:09:47Z", + "url": "https://github.com/666ghj/MiroFish/issues/46#issuecomment-3783323757", + "body_excerpt": "python3.12 venv 可以借解决 tiktoken没有提供高版本python的wheel" + }, + { + "author": "Vamco2022", + "created_at": "2026-01-23T10:34:52Z", + "updated_at": "2026-01-23T10:34:52Z", + "url": "https://github.com/666ghj/MiroFish/issues/46#issuecomment-3789585469", + "body_excerpt": "现在没事了,给自己气笑了,uv自带python的版本问题" + } + ], + "local_coverage": { + "number": 46, + "status": "covered", + "summary": "The setuptools `project.license` warning path is gone because the repo now uses an SPDX license string.", + "local_refs": [ + "backend/pyproject.toml" + ], + "validation": [ + "backend: uv run pytest -q" + ] + }, + "local_status": "covered", + "local_summary": "The setuptools `project.license` warning path is gone because the repo now uses an SPDX license string.", + "triage_status": "covered", + "summary": "The setuptools `project.license` warning path is gone because the repo now uses an SPDX license string.", + "coverage_status": "covered", + "coverage_summary": "The setuptools `project.license` warning path is gone because the repo now uses an SPDX license string.", + "fork_issue_mirrored": true, + "fork_issue_number": 28, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/28" + }, + { + "number": 40, + "title": "更换Zep密钥后后端会仍然使用旧Key", + "url": "https://github.com/666ghj/MiroFish/issues/40", + "state": "closed", + "created_at": "2026-01-20T15:32:59Z", + "updated_at": "2026-01-23T08:20:40Z", + "closed_at": "2026-01-23T08:20:40Z", + "labels": [], + "author": "s1f102500012", + "body_excerpt": "config.py 里 load_dotenv 默认不会覆盖已有环境变量,所以如果曾经 export ZEP_API_KEY=旧值,后端会继续用旧key。个人用修复如图所示。 <img width=\"535\" height=\"435\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/25687484-d607-459e-bef9-9d8e896c9512\" />", + "comment_count": 1, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-01-23T08:20:38Z", + "updated_at": "2026-01-23T08:20:38Z", + "url": "https://github.com/666ghj/MiroFish/issues/40#issuecomment-3789002431", + "body_excerpt": "很棒的bug发现,已修复!" + } + ], + "triage_status": "untracked", + "summary": "config.py 里 load_dotenv 默认不会覆盖已有环境变量,所以如果曾经 export ZEP_API_KEY=旧值,后端会继续用旧key。个人用修复如图所示。 <img width=\"535\" height=\"435\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/25687484-d607-459e-bef9-9d8e896c9512\" />", + "coverage_status": "untracked", + "coverage_summary": "config.py 里 load_dotenv 默认不会覆盖已有环境变量,所以如果曾经 export ZEP_API_KEY=旧值,后端会继续用旧key。个人用修复如图所示。 <img width=\"535\" height=\"435\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/25687484-d607-459e-bef9-9d8e896c9512\" />", + "fork_issue_mirrored": true, + "fork_issue_number": 62, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/62" + }, + { + "number": 50, + "title": "实体重复不一致问题", + "url": "https://github.com/666ghj/MiroFish/issues/50", + "state": "closed", + "created_at": "2026-01-22T13:18:17Z", + "updated_at": "2026-01-23T06:32:15Z", + "closed_at": "2026-01-23T06:32:15Z", + "labels": [], + "author": "ngyygm", + "body_excerpt": "实体重复,同一个角色的多种称呼,比如林墨、林先生、他,这种都会被单独识别出来,可能需要进一步做实体对齐。不然在后面构建人设的时候,会莫名其妙有个“他”这种抽象的角色。", + "comment_count": 1, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-01-23T06:32:14Z", + "updated_at": "2026-01-23T06:32:14Z", + "url": "https://github.com/666ghj/MiroFish/issues/50#issuecomment-3788541679", + "body_excerpt": "对的,这个也是知识图谱领域老生常谈的问题了,还有一条很长的优化之路要走。" + } + ], + "triage_status": "untracked", + "summary": "实体重复,同一个角色的多种称呼,比如林墨、林先生、他,这种都会被单独识别出来,可能需要进一步做实体对齐。不然在后面构建人设的时候,会莫名其妙有个“他”这种抽象的角色。", + "coverage_status": "untracked", + "coverage_summary": "实体重复,同一个角色的多种称呼,比如林墨、林先生、他,这种都会被单独识别出来,可能需要进一步做实体对齐。不然在后面构建人设的时候,会莫名其妙有个“他”这种抽象的角色。", + "fork_issue_mirrored": true, + "fork_issue_number": 63, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/63" + }, + { + "number": 51, + "title": "中英文语言混乱问题", + "url": "https://github.com/666ghj/MiroFish/issues/51", + "state": "closed", + "created_at": "2026-01-22T13:20:20Z", + "updated_at": "2026-01-23T06:31:25Z", + "closed_at": "2026-01-23T06:31:25Z", + "labels": [], + "author": "ngyygm", + "body_excerpt": "实际使用传入的文档是中文文档,由于Prompt的原因。模型会生成很多英文内容,导致实际使用体验会略有下降,而且偶然会出现把实体名写成英文的情况,在图谱构建阶段,就没法正确生成内容。", + "comment_count": 1, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-01-23T06:31:24Z", + "updated_at": "2026-01-23T06:31:24Z", + "url": "https://github.com/666ghj/MiroFish/issues/51#issuecomment-3788539600", + "body_excerpt": "当文本量较大的时候,确实在图谱构建的过程中会有中英文混杂的情况,report也会引用图谱原文导致同样的情况产生,但实际上对质量的影响还在可控范围内,初期可以通过提示词的方式缓解这种现象。" + } + ], + "triage_status": "untracked", + "summary": "实际使用传入的文档是中文文档,由于Prompt的原因。模型会生成很多英文内容,导致实际使用体验会略有下降,而且偶然会出现把实体名写成英文的情况,在图谱构建阶段,就没法正确生成内容。", + "coverage_status": "untracked", + "coverage_summary": "实际使用传入的文档是中文文档,由于Prompt的原因。模型会生成很多英文内容,导致实际使用体验会略有下降,而且偶然会出现把实体名写成英文的情况,在图谱构建阶段,就没法正确生成内容。", + "fork_issue_mirrored": true, + "fork_issue_number": 64, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/64" + }, + { + "number": 55, + "title": "做了一个基于本地neo4j的版本", + "url": "https://github.com/666ghj/MiroFish/issues/55", + "state": "open", + "created_at": "2026-01-23T03:08:03Z", + "updated_at": "2026-01-23T06:27:04Z", + "closed_at": null, + "labels": [], + "author": "xumengke2025-sys", + "body_excerpt": "感谢博主的思路和分享!因为我的zep限额一下就用完了还经常返回超时报错,所以做了个基于本地部署neo4j的版本,neo4j的设置也体现在env文件里,优点是终于稳定了也可以无限生成了,缺点是比zep要慢;可以联系博主直接把项目包发你吗", + "comment_count": 1, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-01-23T06:27:04Z", + "updated_at": "2026-01-23T06:27:04Z", + "url": "https://github.com/666ghj/MiroFish/issues/55#issuecomment-3788527267", + "body_excerpt": "厉害👍,网络上确实有一些本地化的实现方式,如果你愿意的话,可以把你的方法提pr出来,我们会把所有本地化的方案都放在首页供大家参考的。" + } + ], + "local_coverage": { + "number": 55, + "status": "tracked", + "summary": "Tracked under beads issue `mirofish-8eg`: local graph backend alternatives such as Neo4j support need a deliberate backend-abstraction design pass instead of a blind merge of third-party code.", + "local_refs": [ + ".beads/issues.jsonl", + "docs/upstream-triage.md" + ], + "validation": [ + "tracking only" + ] + }, + "local_status": "tracked", + "local_summary": "Tracked under beads issue `mirofish-8eg`: local graph backend alternatives such as Neo4j support need a deliberate backend-abstraction design pass instead of a blind merge of third-party code.", + "triage_status": "tracked", + "summary": "Tracked under beads issue `mirofish-8eg`: local graph backend alternatives such as Neo4j support need a deliberate backend-abstraction design pass instead of a blind merge of third-party code.", + "coverage_status": "tracked", + "coverage_summary": "Tracked under beads issue `mirofish-8eg`: local graph backend alternatives such as Neo4j support need a deliberate backend-abstraction design pass instead of a blind merge of third-party code.", + "fork_issue_mirrored": true, + "fork_issue_number": 29, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/29" + }, + { + "number": 53, + "title": "docker尝试时需要下载4.7个G ~", + "url": "https://github.com/666ghj/MiroFish/issues/53", + "state": "closed", + "created_at": "2026-01-22T14:08:38Z", + "updated_at": "2026-01-23T06:25:40Z", + "closed_at": "2026-01-23T06:25:40Z", + "labels": [], + "author": "lowpair", + "body_excerpt": "如果都是用api方式docker是否可瘦身?其中一个docker包下载需4个g ~", + "comment_count": 2, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-01-22T16:30:49Z", + "updated_at": "2026-01-22T16:30:49Z", + "url": "https://github.com/666ghj/MiroFish/issues/53#issuecomment-3785355163", + "body_excerpt": "这个我还真没注意到,明天看看怎么优化" + }, + { + "author": "666ghj", + "created_at": "2026-01-23T06:25:38Z", + "updated_at": "2026-01-23T06:25:38Z", + "url": "https://github.com/666ghj/MiroFish/issues/53#issuecomment-3788523770", + "body_excerpt": "镜像大是因为依赖了PyTorch,它默认会带上NVIDIA CUDA库,后续会优化成CPU版本,但暂时不打算动。" + } + ], + "triage_status": "untracked", + "summary": "如果都是用api方式docker是否可瘦身?其中一个docker包下载需4个g ~", + "coverage_status": "untracked", + "coverage_summary": "如果都是用api方式docker是否可瘦身?其中一个docker包下载需4个g ~", + "fork_issue_mirrored": true, + "fork_issue_number": 65, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/65" + }, + { + "number": 54, + "title": "太喜欢你这个项目了!给了我巨大的帮助!", + "url": "https://github.com/666ghj/MiroFish/issues/54", + "state": "open", + "created_at": "2026-01-23T02:39:53Z", + "updated_at": "2026-01-23T02:39:53Z", + "closed_at": null, + "labels": [], + "author": "zb2947244682", + "body_excerpt": "太喜欢你这个项目了!给了我巨大的帮助! 我正好也在研究这个方向。", + "comment_count": 0, + "recent_comments": [], + "local_coverage": { + "number": 54, + "status": "no_action", + "summary": "Upstream issue #54 is positive feedback rather than a reproducible bug or scoped feature request, so there is no local implementation task attached to it.", + "local_refs": [ + "docs/upstream-open-state.json" + ], + "validation": [ + "triage only" + ] + }, + "local_status": "no_action", + "local_summary": "Upstream issue #54 is positive feedback rather than a reproducible bug or scoped feature request, so there is no local implementation task attached to it.", + "triage_status": "no_action", + "summary": "Upstream issue #54 is positive feedback rather than a reproducible bug or scoped feature request, so there is no local implementation task attached to it.", + "coverage_status": "no_action", + "coverage_summary": "Upstream issue #54 is positive feedback rather than a reproducible bug or scoped feature request, so there is no local implementation task attached to it.", + "fork_issue_mirrored": true, + "fork_issue_number": 30, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/30" + }, + { + "number": 52, + "title": "API最大长度超过导致程序奔溃", + "url": "https://github.com/666ghj/MiroFish/issues/52", + "state": "open", + "created_at": "2026-01-22T13:25:33Z", + "updated_at": "2026-01-22T13:25:33Z", + "closed_at": null, + "labels": [], + "author": "ngyygm", + "body_excerpt": "在调用自己部署的API时,模型最大长度如果小于18000可能会报错。 个人使用的时候,显示系统传输的token数在16384,我设定的是16000最大长度,然后我改成320000就能正常跑了。", + "comment_count": 0, + "recent_comments": [], + "local_coverage": { + "number": 52, + "status": "covered", + "summary": "Report generation now trims oversized message history, retries after context-length failures, and exposes configurable `LLM_MAX_TOKENS` for smaller-context models.", + "local_refs": [ + "backend/app/services/report_agent.py", + "backend/app/utils/llm_client.py", + "backend/tests/test_report_agent.py", + "backend/tests/test_llm_client.py" + ], + "validation": [ + "backend/tests/test_report_agent.py", + "backend/tests/test_llm_client.py" + ] + }, + "local_status": "covered", + "local_summary": "Report generation now trims oversized message history, retries after context-length failures, and exposes configurable `LLM_MAX_TOKENS` for smaller-context models.", + "triage_status": "covered", + "summary": "Report generation now trims oversized message history, retries after context-length failures, and exposes configurable `LLM_MAX_TOKENS` for smaller-context models.", + "coverage_status": "covered", + "coverage_summary": "Report generation now trims oversized message history, retries after context-length failures, and exposes configurable `LLM_MAX_TOKENS` for smaller-context models.", + "fork_issue_mirrored": true, + "fork_issue_number": 31, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/31" + }, + { + "number": 5, + "title": "是否能提供Docker部署方式", + "url": "https://github.com/666ghj/MiroFish/issues/5", + "state": "closed", + "created_at": "2025-12-23T04:50:17Z", + "updated_at": "2026-01-22T10:31:05Z", + "closed_at": "2026-01-22T10:31:05Z", + "labels": [], + "author": "ghost", + "body_excerpt": "", + "comment_count": 8, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2025-12-25T08:03:10Z", + "updated_at": "2025-12-25T08:03:10Z", + "url": "https://github.com/666ghj/MiroFish/issues/5#issuecomment-3691108206", + "body_excerpt": "会的,最近正在弄" + }, + { + "author": "cnrot", + "created_at": "2025-12-25T08:42:29Z", + "updated_at": "2025-12-25T08:42:46Z", + "url": "https://github.com/666ghj/MiroFish/issues/5#issuecomment-3691158609", + "body_excerpt": "同求 +1" + }, + { + "author": "Dr-jw", + "created_at": "2025-12-26T09:37:36Z", + "updated_at": "2025-12-26T09:37:36Z", + "url": "https://github.com/666ghj/MiroFish/issues/5#issuecomment-3692565486", + "body_excerpt": "> 会的,最近正在弄 太好了" + } + ], + "triage_status": "untracked", + "summary": "", + "coverage_status": "untracked", + "coverage_summary": "", + "fork_issue_mirrored": true, + "fork_issue_number": 66, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/66" + }, + { + "number": 22, + "title": "能否给打包个现成镜像", + "url": "https://github.com/666ghj/MiroFish/issues/22", + "state": "closed", + "created_at": "2026-01-14T09:35:27Z", + "updated_at": "2026-01-22T10:30:47Z", + "closed_at": "2026-01-22T10:30:47Z", + "labels": [], + "author": "busyJoker512", + "body_excerpt": "现在本地部署有报错,🙏", + "comment_count": 2, + "recent_comments": [ + { + "author": "ngstorm", + "created_at": "2026-01-14T09:49:44Z", + "updated_at": "2026-01-14T09:49:44Z", + "url": "https://github.com/666ghj/MiroFish/issues/22#issuecomment-3748694053", + "body_excerpt": "可以写一个DockerFile,分前后端" + }, + { + "author": "666ghj", + "created_at": "2026-01-22T10:30:45Z", + "updated_at": "2026-01-22T10:30:45Z", + "url": "https://github.com/666ghj/MiroFish/issues/22#issuecomment-3783660373", + "body_excerpt": "搞定了,现在可以看一下~" + } + ], + "triage_status": "untracked", + "summary": "现在本地部署有报错,🙏", + "coverage_status": "untracked", + "coverage_summary": "现在本地部署有报错,🙏", + "fork_issue_mirrored": true, + "fork_issue_number": 67, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/67" + }, + { + "number": 47, + "title": "运算过程中,图谱加载失败: Request failed with status code 500", + "url": "https://github.com/666ghj/MiroFish/issues/47", + "state": "closed", + "created_at": "2026-01-21T12:38:56Z", + "updated_at": "2026-01-22T10:30:23Z", + "closed_at": "2026-01-22T10:30:23Z", + "labels": [], + "author": "xumengke2025-sys", + "body_excerpt": "感谢作者分享,这个500报错可能是什么原因", + "comment_count": 1, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-01-22T10:30:20Z", + "updated_at": "2026-01-22T10:30:20Z", + "url": "https://github.com/666ghj/MiroFish/issues/47#issuecomment-3783658389", + "body_excerpt": "应该是请求次数太多了,速率限制了,可以把代码中的请求速率改小一点" + } + ], + "triage_status": "untracked", + "summary": "感谢作者分享,这个500报错可能是什么原因", + "coverage_status": "untracked", + "coverage_summary": "感谢作者分享,这个500报错可能是什么原因", + "fork_issue_mirrored": true, + "fork_issue_number": 68, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/68" + }, + { + "number": 41, + "title": "Zep 可以使用 Neo4j来替代嘛", + "url": "https://github.com/666ghj/MiroFish/issues/41", + "state": "closed", + "created_at": "2026-01-21T01:08:01Z", + "updated_at": "2026-01-22T10:29:48Z", + "closed_at": "2026-01-22T10:29:48Z", + "labels": [], + "author": "dbplayer-git", + "body_excerpt": "", + "comment_count": 2, + "recent_comments": [ + { + "author": "zhangvia", + "created_at": "2026-01-21T06:46:28Z", + "updated_at": "2026-01-21T06:46:28Z", + "url": "https://github.com/666ghj/MiroFish/issues/41#issuecomment-3776456714", + "body_excerpt": "已经有人做了,用neo4j替代的[tt-a1i/MiroFish-local](https://github.com/tt-a1i/MiroFish-local/tree/feat/zep-localization-mvp)" + }, + { + "author": "666ghj", + "created_at": "2026-01-22T10:29:46Z", + "updated_at": "2026-01-22T10:29:46Z", + "url": "https://github.com/666ghj/MiroFish/issues/41#issuecomment-3783655418", + "body_excerpt": "有一些人进行了一些尝试,但是目前还没看到有“可用”的方案诞生,应该最近我们也会弄一版出来,可以等一下" + } + ], + "triage_status": "untracked", + "summary": "", + "coverage_status": "untracked", + "coverage_summary": "", + "fork_issue_mirrored": true, + "fork_issue_number": 69, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/69" + }, + { + "number": 39, + "title": "上传文本时出现Exception in handleNewProject: Network Error的报错", + "url": "https://github.com/666ghj/MiroFish/issues/39", + "state": "closed", + "created_at": "2026-01-20T11:05:39Z", + "updated_at": "2026-01-22T10:28:56Z", + "closed_at": "2026-01-22T10:28:58Z", + "labels": [], + "author": "s1f102500012", + "body_excerpt": "原因是我上传的txt文件不是UTF‑8编码,我个人对file_parser.py进行了功能添加(如图所示),以达到了正常的效果 <img width=\"556\" height=\"442\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/57309429-3bf3-4dc1-a0fa-25b481a008d5\" />", + "comment_count": 2, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-01-20T12:33:05Z", + "updated_at": "2026-01-20T12:33:05Z", + "url": "https://github.com/666ghj/MiroFish/issues/39#issuecomment-3772665166", + "body_excerpt": "很好的思路呀,今晚修复!" + }, + { + "author": "666ghj", + "created_at": "2026-01-22T10:28:56Z", + "updated_at": "2026-01-22T10:28:56Z", + "url": "https://github.com/666ghj/MiroFish/issues/39#issuecomment-3783651863", + "body_excerpt": "已经修复了,感谢你的发现!" + } + ], + "triage_status": "untracked", + "summary": "原因是我上传的txt文件不是UTF‑8编码,我个人对file_parser.py进行了功能添加(如图所示),以达到了正常的效果 <img width=\"556\" height=\"442\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/57309429-3bf3-4dc1-a0fa-25b481a008d5\" />", + "coverage_status": "untracked", + "coverage_summary": "原因是我上传的txt文件不是UTF‑8编码,我个人对file_parser.py进行了功能添加(如图所示),以达到了正常的效果 <img width=\"556\" height=\"442\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/57309429-3bf3-4dc1-a0fa-25b481a008d5\" />", + "fork_issue_mirrored": true, + "fork_issue_number": 70, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/70" + }, + { + "number": 24, + "title": "ERROR: 报告生成失败: 'NoneType' object is not subscriptable", + "url": "https://github.com/666ghj/MiroFish/issues/24", + "state": "open", + "created_at": "2026-01-14T15:53:08Z", + "updated_at": "2026-01-21T13:28:41Z", + "closed_at": null, + "labels": [], + "author": "Joe-rq", + "body_excerpt": "ERROR: 报告生成失败: 'NoneType' object is not subscriptable LLM返回了一个异常长的响应(4856字符),内容是重复的文本\"意识流捕获、语义锚点、灵感固化。探究AI如何处理\"非线性跳跃\"。\"重复了上百次 LLM返回了空响应(response_length: 0),这导致后续代码尝试访问None对象时出错 问题根源: LLM API返回了空响应或异常响应,导致代码在处理时出现 'NoneType' object is not subscriptable 错误。", + "comment_count": 2, + "recent_comments": [ + { + "author": "moonhalf-nostar", + "created_at": "2026-01-19T09:55:33Z", + "updated_at": "2026-01-19T09:55:33Z", + "url": "https://github.com/666ghj/MiroFish/issues/24#issuecomment-3767439957", + "body_excerpt": "可以把详细的日志贴出来,方便后续修复 bug" + }, + { + "author": "xyz50270", + "created_at": "2026-01-21T13:28:41Z", + "updated_at": "2026-01-21T13:28:41Z", + "url": "https://github.com/666ghj/MiroFish/issues/24#issuecomment-3778142354", + "body_excerpt": "碰上一样的情况了 ``` [21:11:54] INFO: 从 reddit_profiles.json 加载了 34 个人设 [21:11:54] INFO: 加载到 34 个Agent人设 [21:12:09] INFO: 选择了 5 个Agent进行采访: [1, 2, 3, 30, 6] [21:12:22] INFO: 生成了 5 个采访问题 [21:12:22] INFO: 调用批量采访API(双平台): 5 个Agent [21:12:37] INFO: 采访…" + } + ], + "local_coverage": { + "number": 24, + "status": "covered", + "summary": "Report generation now tolerates empty LLM section responses by retrying, then falling back to a per-section placeholder instead of crashing the whole report flow with a `NoneType` subscript error.", + "local_refs": [ + "backend/app/services/report_agent.py", + "backend/tests/test_report_agent.py" + ], + "validation": [ + "backend/tests/test_report_agent.py::test_generate_report_survives_empty_llm_section_responses" + ] + }, + "local_status": "covered", + "local_summary": "Report generation now tolerates empty LLM section responses by retrying, then falling back to a per-section placeholder instead of crashing the whole report flow with a `NoneType` subscript error.", + "triage_status": "covered", + "summary": "Report generation now tolerates empty LLM section responses by retrying, then falling back to a per-section placeholder instead of crashing the whole report flow with a `NoneType` subscript error.", + "coverage_status": "covered", + "coverage_summary": "Report generation now tolerates empty LLM section responses by retrying, then falling back to a per-section placeholder instead of crashing the whole report flow with a `NoneType` subscript error.", + "fork_issue_mirrored": true, + "fork_issue_number": 32, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/32" + }, + { + "number": 45, + "title": "与世界中任意个体对话功能和发送调查问卷到世界中 两个功能报错 IPC 响应 failed", + "url": "https://github.com/666ghj/MiroFish/issues/45", + "state": "open", + "created_at": "2026-01-21T06:46:41Z", + "updated_at": "2026-01-21T07:11:18Z", + "closed_at": null, + "labels": [], + "author": "LucasXu666666", + "body_excerpt": "前端报错信息: <img width=\"1453\" height=\"588\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/ed8cdb71-c7b8-463c-b287-e69ae82ae474\" /> 后端报错信息: [backend] [14:42:38] INFO: 发送批量Interview命令: simulation_id=sim_4ba82ee5afeb, count=1, platform=None [backend] [14:42:38] INFO: 发送IPC命令: batch_interview, command_id=fc8bf264-c5c5-43ab-bbe4-c93f99d55cb3 [backend] [14:42:39] INFO: 收到IPC响应: command_id=fc8b…", + "comment_count": 1, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-01-21T07:11:18Z", + "updated_at": "2026-01-21T07:11:18Z", + "url": "https://github.com/666ghj/MiroFish/issues/45#issuecomment-3776523496", + "body_excerpt": "interview_agents 方法依赖于模拟环境处于运行状态才能运行,也就是需要从第三步跑完以后继续执行才行" + } + ], + "local_coverage": { + "number": 45, + "status": "covered", + "summary": "Step 5 now preserves platform metadata and targets Reddit/Twitter interviews against the matching backend instead of assuming a Reddit-only profile list.", + "local_refs": [ + "frontend/src/components/Step5Interaction.vue", + "frontend/src/components/step5Profiles.js", + "frontend/tests/step5Profiles.test.mjs" + ], + "validation": [ + "frontend/tests/step5Profiles.test.mjs", + "frontend: npm run build" + ] + }, + "local_status": "covered", + "local_summary": "Step 5 now preserves platform metadata and targets Reddit/Twitter interviews against the matching backend instead of assuming a Reddit-only profile list.", + "triage_status": "covered", + "summary": "Step 5 now preserves platform metadata and targets Reddit/Twitter interviews against the matching backend instead of assuming a Reddit-only profile list.", + "coverage_status": "covered", + "coverage_summary": "Step 5 now preserves platform metadata and targets Reddit/Twitter interviews against the matching backend instead of assuming a Reddit-only profile list.", + "fork_issue_mirrored": true, + "fork_issue_number": 33, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/33" + }, + { + "number": 43, + "title": "在使用时调用IPC超时", + "url": "https://github.com/666ghj/MiroFish/issues/43", + "state": "open", + "created_at": "2026-01-21T02:23:35Z", + "updated_at": "2026-01-21T07:10:12Z", + "closed_at": null, + "labels": [], + "author": "dbplayer-git", + "body_excerpt": "<img width=\"1446\" height=\"543\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/5d101d3b-6dbc-43c4-8c68-2493f9badad3\" />", + "comment_count": 1, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-01-21T07:10:12Z", + "updated_at": "2026-01-21T07:10:12Z", + "url": "https://github.com/666ghj/MiroFish/issues/43#issuecomment-3776520523", + "body_excerpt": "是不是模拟世界太大了,这样采访接口就会好久" + } + ], + "local_coverage": { + "number": 43, + "status": "covered", + "summary": "Step 5 now rewrites timeout and environment-closed failures into actionable guidance, and the backend/frontend timeout knobs give slow local models an explicit supported path.", + "local_refs": [ + "frontend/src/components/Step5Interaction.vue", + "frontend/src/api/timeout.js", + ".env.example" + ], + "validation": [ + "frontend: npm test", + "frontend: npm run build" + ] + }, + "local_status": "covered", + "local_summary": "Step 5 now rewrites timeout and environment-closed failures into actionable guidance, and the backend/frontend timeout knobs give slow local models an explicit supported path.", + "triage_status": "covered", + "summary": "Step 5 now rewrites timeout and environment-closed failures into actionable guidance, and the backend/frontend timeout knobs give slow local models an explicit supported path.", + "coverage_status": "covered", + "coverage_summary": "Step 5 now rewrites timeout and environment-closed failures into actionable guidance, and the backend/frontend timeout knobs give slow local models an explicit supported path.", + "fork_issue_mirrored": true, + "fork_issue_number": 34, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/34" + }, + { + "number": 23, + "title": "有没有开源替换zep,消耗太快了", + "url": "https://github.com/666ghj/MiroFish/issues/23", + "state": "closed", + "created_at": "2026-01-14T09:50:25Z", + "updated_at": "2026-01-20T08:05:29Z", + "closed_at": "2026-01-20T08:05:29Z", + "labels": [], + "author": "ngstorm", + "body_excerpt": "有没有开源替换zep,消耗太快了", + "comment_count": 9, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-01-14T10:13:57Z", + "updated_at": "2026-01-14T10:14:14Z", + "url": "https://github.com/666ghj/MiroFish/issues/23#issuecomment-3748793141", + "body_excerpt": "zep是有开源版本的,可以本地部署,完成本地记忆层的改造。现在项目用在线版本的云服务主要是为了方便大家,填个api就能跑" + }, + { + "author": "ngstorm", + "created_at": "2026-01-14T11:33:11Z", + "updated_at": "2026-01-14T11:33:11Z", + "url": "https://github.com/666ghj/MiroFish/issues/23#issuecomment-3749128647", + "body_excerpt": "zep开源集成有说明吗??" + }, + { + "author": "666ghj", + "created_at": "2026-01-14T14:36:09Z", + "updated_at": "2026-01-14T14:36:09Z", + "url": "https://github.com/666ghj/MiroFish/issues/23#issuecomment-3749856666", + "body_excerpt": "> zep开源集成有说明吗?? 可以自行搜索调研一下,后续会专门开一个本地版本的graphrag替代分支" + } + ], + "triage_status": "untracked", + "summary": "有没有开源替换zep,消耗太快了", + "coverage_status": "untracked", + "coverage_summary": "有没有开源替换zep,消耗太快了", + "fork_issue_mirrored": true, + "fork_issue_number": 71, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/71" + }, + { + "number": 4, + "title": "So powerful!", + "url": "https://github.com/666ghj/MiroFish/issues/4", + "state": "closed", + "created_at": "2025-12-23T02:17:13Z", + "updated_at": "2026-01-20T08:05:04Z", + "closed_at": "2026-01-20T08:05:04Z", + "labels": [], + "author": "ichont", + "body_excerpt": "", + "comment_count": 1, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2025-12-25T08:03:34Z", + "updated_at": "2025-12-25T08:03:34Z", + "url": "https://github.com/666ghj/MiroFish/issues/4#issuecomment-3691108696", + "body_excerpt": "感谢兄弟支持😉" + } + ], + "triage_status": "untracked", + "summary": "", + "coverage_status": "untracked", + "coverage_summary": "", + "fork_issue_mirrored": true, + "fork_issue_number": 72, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/72" + }, + { + "number": 3, + "title": "很酷的项目", + "url": "https://github.com/666ghj/MiroFish/issues/3", + "state": "closed", + "created_at": "2025-12-23T02:04:12Z", + "updated_at": "2026-01-20T08:04:59Z", + "closed_at": "2026-01-20T08:04:59Z", + "labels": [], + "author": "sairson", + "body_excerpt": "", + "comment_count": 1, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2025-12-25T08:03:58Z", + "updated_at": "2025-12-25T08:03:58Z", + "url": "https://github.com/666ghj/MiroFish/issues/3#issuecomment-3691109209", + "body_excerpt": "🥳" + } + ], + "triage_status": "untracked", + "summary": "", + "coverage_status": "untracked", + "coverage_summary": "", + "fork_issue_mirrored": true, + "fork_issue_number": 73, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/73" + }, + { + "number": 8, + "title": "请问用哪个模型效果最稳定", + "url": "https://github.com/666ghj/MiroFish/issues/8", + "state": "closed", + "created_at": "2025-12-27T03:28:24Z", + "updated_at": "2026-01-20T08:04:52Z", + "closed_at": "2026-01-20T08:04:52Z", + "labels": [], + "author": "zellelin2023-create", + "body_excerpt": "", + "comment_count": 1, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2025-12-27T14:52:19Z", + "updated_at": "2025-12-27T14:52:19Z", + "url": "https://github.com/666ghj/MiroFish/issues/8#issuecomment-3694024007", + "body_excerpt": "阿里百炼平台的qwen-plus吧我测试的时候一直用的这个" + } + ], + "triage_status": "untracked", + "summary": "", + "coverage_status": "untracked", + "coverage_summary": "", + "fork_issue_mirrored": true, + "fork_issue_number": 74, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/74" + }, + { + "number": 26, + "title": "开始模拟没响应,F12查看接口报进程错误", + "url": "https://github.com/666ghj/MiroFish/issues/26", + "state": "closed", + "created_at": "2026-01-15T03:54:47Z", + "updated_at": "2026-01-20T08:04:13Z", + "closed_at": "2026-01-20T08:04:13Z", + "labels": [], + "author": "ivwpuw", + "body_excerpt": "已经执行到第三步“开始模拟”了,但是点击开始后工作台面板一直空白没输出,然后按F12查看有进程报错,但具体的终端日志又没有 <img width=\"1349\" height=\"916\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/7d9185f2-fc50-46bc-92ac-b45f129e3b3e\" /> <img width=\"1094\" height=\"613\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/a5dc0291-a7aa-4780-a367-10eecddd4fc7\" />", + "comment_count": 5, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-01-15T05:47:07Z", + "updated_at": "2026-01-15T05:47:07Z", + "url": "https://github.com/666ghj/MiroFish/issues/26#issuecomment-3752999547", + "body_excerpt": "看一下后端文件夹中的log?uploads文件夹中的对应simulation的log文件,找一下" + }, + { + "author": "ivwpuw", + "created_at": "2026-01-15T06:20:21Z", + "updated_at": "2026-01-15T06:20:21Z", + "url": "https://github.com/666ghj/MiroFish/issues/26#issuecomment-3753068407", + "body_excerpt": "@666ghj 有看到报错日志,然后怎么解决呢? <img width=\"1253\" height=\"683\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/8c9781bb-2e4c-4b95-aec0-18fc6e342200\" /> <img width=\"671\" height=\"457\" alt=\"Image\" src=\"https://github.com/user-attachments…" + }, + { + "author": "ivwpuw", + "created_at": "2026-01-15T06:24:17Z", + "updated_at": "2026-01-15T06:24:17Z", + "url": "https://github.com/666ghj/MiroFish/issues/26#issuecomment-3753078032", + "body_excerpt": "是在Windows 11系统下启动的" + } + ], + "triage_status": "untracked", + "summary": "已经执行到第三步“开始模拟”了,但是点击开始后工作台面板一直空白没输出,然后按F12查看有进程报错,但具体的终端日志又没有 <img width=\"1349\" height=\"916\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/7d9185f2-fc50-46bc-92ac-b45f129e3b3e\" /> <img width=\"1094\" height=\"613\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/a5dc0291-a7aa-4780-a367-10eecddd4fc7\" />", + "coverage_status": "untracked", + "coverage_summary": "已经执行到第三步“开始模拟”了,但是点击开始后工作台面板一直空白没输出,然后按F12查看有进程报错,但具体的终端日志又没有 <img width=\"1349\" height=\"916\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/7d9185f2-fc50-46bc-92ac-b45f129e3b3e\" /> <img width=\"1094\" height=\"613\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/a5dc0291-a7aa-4780-a367-10eecddd4fc7\" />", + "fork_issue_mirrored": true, + "fork_issue_number": 75, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/75" + }, + { + "number": 27, + "title": "Zep的免费计划总是报错:Rate Limit Exceeded", + "url": "https://github.com/666ghj/MiroFish/issues/27", + "state": "closed", + "created_at": "2026-01-15T09:02:46Z", + "updated_at": "2026-01-20T08:03:36Z", + "closed_at": "2026-01-20T08:03:36Z", + "labels": [], + "author": "devTech-zhang", + "body_excerpt": "<img width=\"1648\" height=\"686\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/af0a8a0b-3a74-45dc-858f-bb9ec8640889\" /> /api/graph/data/mirofish_6e4b03b4387b4eda 这个接口报错概率特别高,但偶尔又能有好几个连续正常的,这是怎么回事呢 <img width=\"2342\" height=\"173\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/e6f4d955-70a3-499d-a57b-68f12c33962d\" /> 报错信息:headers: {'date': 'Thu, 15 Jan 2026 09:00:53 GMT', 'c…", + "comment_count": 1, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-01-20T08:03:31Z", + "updated_at": "2026-01-20T08:03:31Z", + "url": "https://github.com/666ghj/MiroFish/issues/27#issuecomment-3771549066", + "body_excerpt": "zep 免费账户速率限制了" + } + ], + "triage_status": "untracked", + "summary": "<img width=\"1648\" height=\"686\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/af0a8a0b-3a74-45dc-858f-bb9ec8640889\" /> /api/graph/data/mirofish_6e4b03b4387b4eda 这个接口报错概率特别高,但偶尔又能有好几个连续正常的,这是怎么回事呢 <img width=\"2342\" height=\"173\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/e6f4d955-70a3-499d-a57b-68f12c33962d\" /> 报错信息:headers: {'date': 'Thu, 15 Jan 2026 09:00:53 GMT', 'c…", + "coverage_status": "untracked", + "coverage_summary": "<img width=\"1648\" height=\"686\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/af0a8a0b-3a74-45dc-858f-bb9ec8640889\" /> /api/graph/data/mirofish_6e4b03b4387b4eda 这个接口报错概率特别高,但偶尔又能有好几个连续正常的,这是怎么回事呢 <img width=\"2342\" height=\"173\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/e6f4d955-70a3-499d-a57b-68f12c33962d\" /> 报错信息:headers: {'date': 'Thu, 15 Jan 2026 09:00:53 GMT', 'c…", + "fork_issue_mirrored": true, + "fork_issue_number": 76, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/76" + }, + { + "number": 29, + "title": "怎么样停用加速 LLM 配置?不然会一直报gpt-4o-mini模型相关错", + "url": "https://github.com/666ghj/MiroFish/issues/29", + "state": "closed", + "created_at": "2026-01-15T10:29:35Z", + "updated_at": "2026-01-20T08:03:13Z", + "closed_at": "2026-01-20T08:03:13Z", + "labels": [], + "author": "ivwpuw", + "body_excerpt": "如图,执行一段时间后,通过日志查看会一直报错:2026-01-15 17:24:25,406 - camel.camel.agents.chat_agent - ERROR - Model error: gpt-4o-mini Traceback (most recent call last): File \"F:\\AI应用\\\\MiroFish\\MiroFish-0.1.0\\backend\\.venv\\Lib\\site-packages\\httpx\\_transports\\default.py\", line 101, in map_httpcore_exceptions yield File \"F:\\AI应用\\\\MiroFish\\MiroFish-0.1.0\\backend\\.venv\\Lib\\site-packages\\httpx\\_transports\\default.py\", lin…", + "comment_count": 1, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-01-15T14:59:36Z", + "updated_at": "2026-01-15T15:00:07Z", + "url": "https://github.com/666ghj/MiroFish/issues/29#issuecomment-3755280454", + "body_excerpt": "如果不用加速llm api,env里原来就不要出现那个配置项,就会停止,你只要写了参数但是没给数值也不行" + } + ], + "triage_status": "untracked", + "summary": "如图,执行一段时间后,通过日志查看会一直报错:2026-01-15 17:24:25,406 - camel.camel.agents.chat_agent - ERROR - Model error: gpt-4o-mini Traceback (most recent call last): File \"F:\\AI应用\\\\MiroFish\\MiroFish-0.1.0\\backend\\.venv\\Lib\\site-packages\\httpx\\_transports\\default.py\", line 101, in map_httpcore_exceptions yield File \"F:\\AI应用\\\\MiroFish\\MiroFish-0.1.0\\backend\\.venv\\Lib\\site-packages\\httpx\\_transports\\default.py\", lin…", + "coverage_status": "untracked", + "coverage_summary": "如图,执行一段时间后,通过日志查看会一直报错:2026-01-15 17:24:25,406 - camel.camel.agents.chat_agent - ERROR - Model error: gpt-4o-mini Traceback (most recent call last): File \"F:\\AI应用\\\\MiroFish\\MiroFish-0.1.0\\backend\\.venv\\Lib\\site-packages\\httpx\\_transports\\default.py\", line 101, in map_httpcore_exceptions yield File \"F:\\AI应用\\\\MiroFish\\MiroFish-0.1.0\\backend\\.venv\\Lib\\site-packages\\httpx\\_transports\\default.py\", lin…", + "fork_issue_mirrored": true, + "fork_issue_number": 77, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/77" + }, + { + "number": 28, + "title": "建议:增加,网络搜索的选项", + "url": "https://github.com/666ghj/MiroFish/issues/28", + "state": "closed", + "created_at": "2026-01-15T09:35:55Z", + "updated_at": "2026-01-20T08:02:04Z", + "closed_at": "2026-01-20T08:02:04Z", + "labels": [], + "author": "ferocknew", + "body_excerpt": "如题", + "comment_count": 1, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-01-20T08:02:03Z", + "updated_at": "2026-01-20T08:02:03Z", + "url": "https://github.com/666ghj/MiroFish/issues/28#issuecomment-3771544318", + "body_excerpt": "这个现在还是跟其他分析工具结合使用,作为BettaFish的续作,BettaFish完成“数据联网收集->数据分析”,MiroFish完成“数据预测”。BettaFish是“后视镜”,MiroFish是“望远镜”。" + } + ], + "triage_status": "untracked", + "summary": "如题", + "coverage_status": "untracked", + "coverage_summary": "如题", + "fork_issue_mirrored": true, + "fork_issue_number": 78, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/78" + }, + { + "number": 30, + "title": "报告生成这一步失败了,刷新网页无任何重试效果", + "url": "https://github.com/666ghj/MiroFish/issues/30", + "state": "closed", + "created_at": "2026-01-16T01:32:16Z", + "updated_at": "2026-01-20T07:59:15Z", + "closed_at": "2026-01-20T07:59:15Z", + "labels": [], + "author": "stone100010", + "body_excerpt": "<img width=\"1920\" height=\"916\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/bad623b5-1b31-4ceb-be1e-cc2f970b2cf4\" /> [01:12:08] INFO: 搜索完成: 找到 30 条相关事实 [01:12:08] INFO: 获取图谱 mirofish_6608aaa3e89240a9 的统计信息... [01:12:08] INFO: 获取图谱 mirofish_6608aaa3e89240a9 的所有节点... [01:12:09] INFO: 获取到 262 个节点 [01:12:09] INFO: 获取图谱 mirofish_6608aaa3e89240a9 的所有边... [01:12:10] INFO: 获取到 1328 条边 [01:…", + "comment_count": 3, + "recent_comments": [ + { + "author": "moonhalf-nostar", + "created_at": "2026-01-19T09:43:50Z", + "updated_at": "2026-01-19T09:43:50Z", + "url": "https://github.com/666ghj/MiroFish/issues/30#issuecomment-3767386939", + "body_excerpt": "这个是 zep 免费账户速率限制了,可以考虑过段时间尝试,或是充值 zep" + }, + { + "author": "stone100010", + "created_at": "2026-01-20T07:13:24Z", + "updated_at": "2026-01-20T07:13:24Z", + "url": "https://github.com/666ghj/MiroFish/issues/30#issuecomment-3771375676", + "body_excerpt": "现在过去好几天了,额度应该恢复了吧,我想在当前任务上继续后续步骤,有什么方法吗?" + }, + { + "author": "666ghj", + "created_at": "2026-01-20T07:59:14Z", + "updated_at": "2026-01-20T07:59:14Z", + "url": "https://github.com/666ghj/MiroFish/issues/30#issuecomment-3771533260", + "body_excerpt": "zep 免费账户速率限制了,每个月额度一刷新的,要么充值zep要么换个邮箱" + } + ], + "triage_status": "untracked", + "summary": "<img width=\"1920\" height=\"916\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/bad623b5-1b31-4ceb-be1e-cc2f970b2cf4\" /> [01:12:08] INFO: 搜索完成: 找到 30 条相关事实 [01:12:08] INFO: 获取图谱 mirofish_6608aaa3e89240a9 的统计信息... [01:12:08] INFO: 获取图谱 mirofish_6608aaa3e89240a9 的所有节点... [01:12:09] INFO: 获取到 262 个节点 [01:12:09] INFO: 获取图谱 mirofish_6608aaa3e89240a9 的所有边... [01:12:10] INFO: 获取到 1328 条边 [01:…", + "coverage_status": "untracked", + "coverage_summary": "<img width=\"1920\" height=\"916\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/bad623b5-1b31-4ceb-be1e-cc2f970b2cf4\" /> [01:12:08] INFO: 搜索完成: 找到 30 条相关事实 [01:12:08] INFO: 获取图谱 mirofish_6608aaa3e89240a9 的统计信息... [01:12:08] INFO: 获取图谱 mirofish_6608aaa3e89240a9 的所有节点... [01:12:09] INFO: 获取到 262 个节点 [01:12:09] INFO: 获取图谱 mirofish_6608aaa3e89240a9 的所有边... [01:12:10] INFO: 获取到 1328 条边 [01:…", + "fork_issue_mirrored": true, + "fork_issue_number": 79, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/79" + }, + { + "number": 31, + "title": "请问如何减少第三步开始模拟的并发数量?RPM超出限制报500错误。", + "url": "https://github.com/666ghj/MiroFish/issues/31", + "state": "closed", + "created_at": "2026-01-17T00:34:37Z", + "updated_at": "2026-01-20T07:58:19Z", + "closed_at": "2026-01-20T07:58:19Z", + "labels": [], + "author": "Neal3923", + "body_excerpt": "因为我使用的是第三方接口限制每分钟10的RPM,这就导致一直报500错误。 08:23:05.542图谱加载失败: Request failed with status code 500 08:23:09.909[Plaza] R109/240 | T:0h | A:12 08:23:17.925[Plaza] R110/240 | T:0h | A:12 08:23:25.917[Plaza] R111/240 | T:0h | A:12 08:23:35.543图谱加载失败: Request failed with status code 500 08:23:39.975[Community] R232/240 | T:0h | A:12 08:23:41.973[Community] R233/240 | T:0h | A:12", + "comment_count": 1, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-01-20T07:58:15Z", + "updated_at": "2026-01-20T07:58:15Z", + "url": "https://github.com/666ghj/MiroFish/issues/31#issuecomment-3771529269", + "body_excerpt": "在`backend/scripts/run_parallel_simulation.py`文件中: ```python result.env = oasis.make( agent_graph=result.agent_graph, platform=oasis.DefaultPlatformType.TWITTER, database_path=db_path, semaphore=30, # 限制最大并发 LLM 请求数,防止 API 过载 ``` 以及在`get_ac…" + } + ], + "triage_status": "untracked", + "summary": "因为我使用的是第三方接口限制每分钟10的RPM,这就导致一直报500错误。 08:23:05.542图谱加载失败: Request failed with status code 500 08:23:09.909[Plaza] R109/240 | T:0h | A:12 08:23:17.925[Plaza] R110/240 | T:0h | A:12 08:23:25.917[Plaza] R111/240 | T:0h | A:12 08:23:35.543图谱加载失败: Request failed with status code 500 08:23:39.975[Community] R232/240 | T:0h | A:12 08:23:41.973[Community] R233/240 | T:0h | A:12", + "coverage_status": "untracked", + "coverage_summary": "因为我使用的是第三方接口限制每分钟10的RPM,这就导致一直报500错误。 08:23:05.542图谱加载失败: Request failed with status code 500 08:23:09.909[Plaza] R109/240 | T:0h | A:12 08:23:17.925[Plaza] R110/240 | T:0h | A:12 08:23:25.917[Plaza] R111/240 | T:0h | A:12 08:23:35.543图谱加载失败: Request failed with status code 500 08:23:39.975[Community] R232/240 | T:0h | A:12 08:23:41.973[Community] R233/240 | T:0h | A:12", + "fork_issue_mirrored": true, + "fork_issue_number": 80, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/80" + }, + { + "number": 34, + "title": "建议新增“热门榜单”,根据热门做选题。", + "url": "https://github.com/666ghj/MiroFish/issues/34", + "state": "closed", + "created_at": "2026-01-19T02:12:55Z", + "updated_at": "2026-01-20T07:54:41Z", + "closed_at": "2026-01-20T07:54:41Z", + "labels": [], + "author": "lpf479", + "body_excerpt": "热门榜单有助于事件获取,当前时间需要上传文档可结合起来,利用“热门榜单”+“联网搜索”生成事件,比单纯上传文件更合适。", + "comment_count": 1, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-01-20T07:54:39Z", + "updated_at": "2026-01-20T07:54:39Z", + "url": "https://github.com/666ghj/MiroFish/issues/34#issuecomment-3771517407", + "body_excerpt": "对的,可以把MiroFish跟BettaFish结合起来用,BettaFish是他的前作,做的就是你这个事情" + } + ], + "triage_status": "untracked", + "summary": "热门榜单有助于事件获取,当前时间需要上传文档可结合起来,利用“热门榜单”+“联网搜索”生成事件,比单纯上传文件更合适。", + "coverage_status": "untracked", + "coverage_summary": "热门榜单有助于事件获取,当前时间需要上传文档可结合起来,利用“热门榜单”+“联网搜索”生成事件,比单纯上传文件更合适。", + "fork_issue_mirrored": true, + "fork_issue_number": 81, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/81" + }, + { + "number": 35, + "title": "Zep建议替换为本地构建", + "url": "https://github.com/666ghj/MiroFish/issues/35", + "state": "closed", + "created_at": "2026-01-19T09:01:32Z", + "updated_at": "2026-01-20T07:53:14Z", + "closed_at": "2026-01-20T07:53:14Z", + "labels": [], + "author": "jjy1000", + "body_excerpt": "建议使用替换为本地构建,使用第三方向量和重排模型来进行图谱构建", + "comment_count": 1, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-01-20T07:53:12Z", + "updated_at": "2026-01-20T07:53:12Z", + "url": "https://github.com/666ghj/MiroFish/issues/35#issuecomment-3771512931", + "body_excerpt": "这个我在其他issue中进行了一些回答,本地解决方案有很多,大家可以自行调研,后续我也会开个分支单独提供本地RAG版本。 但是现在使用zep的主要原因是为了配置方便,大家填个api就能跑,zep的免费额度也够用一两次,所以暂时接api" + } + ], + "triage_status": "untracked", + "summary": "建议使用替换为本地构建,使用第三方向量和重排模型来进行图谱构建", + "coverage_status": "untracked", + "coverage_summary": "建议使用替换为本地构建,使用第三方向量和重排模型来进行图谱构建", + "fork_issue_mirrored": true, + "fork_issue_number": 82, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/82" + }, + { + "number": 36, + "title": "uv环境建议加上清华源", + "url": "https://github.com/666ghj/MiroFish/issues/36", + "state": "closed", + "created_at": "2026-01-20T07:24:40Z", + "updated_at": "2026-01-20T07:49:59Z", + "closed_at": "2026-01-20T07:49:59Z", + "labels": [], + "author": "Ezj-Amon", + "body_excerpt": "\"setup:backend\": \"cd backend && uv sync --index-url https://pypi.tuna.tsinghua.edu.cn/simple\",", + "comment_count": 1, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-01-20T07:49:57Z", + "updated_at": "2026-01-20T07:49:57Z", + "url": "https://github.com/666ghj/MiroFish/issues/36#issuecomment-3771502674", + "body_excerpt": "这里就默认大家都有相关基础能力了" + } + ], + "triage_status": "untracked", + "summary": "\"setup:backend\": \"cd backend && uv sync --index-url https://pypi.tuna.tsinghua.edu.cn/simple\",", + "coverage_status": "untracked", + "coverage_summary": "\"setup:backend\": \"cd backend && uv sync --index-url https://pypi.tuna.tsinghua.edu.cn/simple\",", + "fork_issue_mirrored": true, + "fork_issue_number": 83, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/83" + }, + { + "number": 32, + "title": "无法识别第三方兼容的openai接口,必须在.env添加LLM_PROVIDER=openai后才能识别", + "url": "https://github.com/666ghj/MiroFish/issues/32", + "state": "closed", + "created_at": "2026-01-17T00:38:27Z", + "updated_at": "2026-01-20T07:48:26Z", + "closed_at": "2026-01-20T07:48:26Z", + "labels": [], + "author": "Neal3923", + "body_excerpt": "我使用的是第三方的官方中转兼容openai接口,直接填写url和key会报500错误,必须在.evn添加:LLM_PROVIDER=openai 才能正常运行,是否需要在代码里面添加一个识别?用第三方接口的人应该不在少数,因为性价比比较高。", + "comment_count": 2, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-01-20T07:45:55Z", + "updated_at": "2026-01-20T07:45:55Z", + "url": "https://github.com/666ghj/MiroFish/issues/32#issuecomment-3771485158", + "body_excerpt": "1. 我们现在的后端代码根本没有处理.evn中的LLM_PROVIDER参数的逻辑,仅仅是读取原来需要的配置项。所以你加不加这个参数对程序的运行没有影响,一开始500后面不报错了应该是其他原因,不是这个导致的。 2. 现在程序兼容openai接口并且只使用这一种格式,LLM_PROVIDER=openai 是被硬编码在程序中的,只要是很好的兼容openai sdk的调用格式按理来说都是可以的,推荐使用阿里百炼、deepseek等官方api,会对openai等格式兼容比较好,…" + }, + { + "author": "666ghj", + "created_at": "2026-01-20T07:48:16Z", + "updated_at": "2026-01-20T07:48:16Z", + "url": "https://github.com/666ghj/MiroFish/issues/32#issuecomment-3771494090", + "body_excerpt": "### 五个 step 里哪些地方用到了 LLM、怎么取 env、会不会也需要 LLM_PROVIDER Step1(图谱/本体):OntologyGenerator → LLMClient.chat_json() → 读 LLM_API_KEY/LLM_BASE_URL/LLM_MODEL_NAME。不读取 LLM_PROVIDER,但强依赖 JSON mode。 Step2(人设+模拟配置):OasisProfileGenerator、SimulationConfigG…" + } + ], + "triage_status": "untracked", + "summary": "我使用的是第三方的官方中转兼容openai接口,直接填写url和key会报500错误,必须在.evn添加:LLM_PROVIDER=openai 才能正常运行,是否需要在代码里面添加一个识别?用第三方接口的人应该不在少数,因为性价比比较高。", + "coverage_status": "untracked", + "coverage_summary": "我使用的是第三方的官方中转兼容openai接口,直接填写url和key会报500错误,必须在.evn添加:LLM_PROVIDER=openai 才能正常运行,是否需要在代码里面添加一个识别?用第三方接口的人应该不在少数,因为性价比比较高。", + "fork_issue_mirrored": true, + "fork_issue_number": 84, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/84" + }, + { + "number": 19, + "title": "建议初次使用不用太大的pdf", + "url": "https://github.com/666ghj/MiroFish/issues/19", + "state": "open", + "created_at": "2026-01-14T02:45:19Z", + "updated_at": "2026-01-15T05:57:37Z", + "closed_at": null, + "labels": [], + "author": "paperplane123", + "body_excerpt": "没跑完,我的zep一个月额度就没有了,下个月再试吧", + "comment_count": 1, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-01-15T05:57:37Z", + "updated_at": "2026-01-15T05:57:37Z", + "url": "https://github.com/666ghj/MiroFish/issues/19#issuecomment-3753021224", + "body_excerpt": "对的,还是建议一万字以内,30轮左右模拟" + } + ], + "local_coverage": { + "number": 19, + "status": "covered", + "summary": "The home upload flow and quick-start docs now explicitly recommend starting with smaller source material (roughly 10k words) and about 30 simulation rounds on a first run, reducing avoidable quota burn and onboarding confusion.", + "local_refs": [ + "frontend/src/views/Home.vue", + "frontend/src/i18n/locales/zh.js", + "frontend/src/i18n/locales/en.js", + "README.md", + "README-EN.md" + ], + "validation": [ + "frontend: npm run build" + ] + }, + "local_status": "covered", + "local_summary": "The home upload flow and quick-start docs now explicitly recommend starting with smaller source material (roughly 10k words) and about 30 simulation rounds on a first run, reducing avoidable quota burn and onboarding confusion.", + "triage_status": "covered", + "summary": "The home upload flow and quick-start docs now explicitly recommend starting with smaller source material (roughly 10k words) and about 30 simulation rounds on a first run, reducing avoidable quota burn and onboarding confusion.", + "coverage_status": "covered", + "coverage_summary": "The home upload flow and quick-start docs now explicitly recommend starting with smaller source material (roughly 10k words) and about 30 simulation rounds on a first run, reducing avoidable quota burn and onboarding confusion.", + "fork_issue_mirrored": true, + "fork_issue_number": 35, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/35" + }, + { + "number": 21, + "title": "部署在群辉服务器上,docker里启动了服务,然后用过反向代理可以访问前端了,如果已经开始模拟了,刷新网页或者关掉再打开会发生什么", + "url": "https://github.com/666ghj/MiroFish/issues/21", + "state": "open", + "created_at": "2026-01-14T08:33:53Z", + "updated_at": "2026-01-14T08:33:53Z", + "closed_at": null, + "labels": [], + "author": "usernametooshort", + "body_excerpt": "做准备等了半天,不敢试,但是我觉得docker在服务器端,我关掉网页再打开应该也还是可以恢复吧 <img width=\"1728\" height=\"965\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/f132121a-ee66-4623-880b-3ea8782c2265\" />", + "comment_count": 0, + "recent_comments": [], + "local_coverage": { + "number": 21, + "status": "covered", + "summary": "The docs/UI now clarify that refreshing the browser does not stop backend jobs, persisted runs remain reopenable from history, and Step 3/5 still require a live runtime session.", + "local_refs": [ + "frontend/src/components/HistoryDatabase.vue", + "README.md", + "README-EN.md" + ], + "validation": [ + "frontend: npm run build" + ] + }, + "local_status": "covered", + "local_summary": "The docs/UI now clarify that refreshing the browser does not stop backend jobs, persisted runs remain reopenable from history, and Step 3/5 still require a live runtime session.", + "triage_status": "covered", + "summary": "The docs/UI now clarify that refreshing the browser does not stop backend jobs, persisted runs remain reopenable from history, and Step 3/5 still require a live runtime session.", + "coverage_status": "covered", + "coverage_summary": "The docs/UI now clarify that refreshing the browser does not stop backend jobs, persisted runs remain reopenable from history, and Step 3/5 still require a live runtime session.", + "fork_issue_mirrored": true, + "fork_issue_number": 36, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/36" + }, + { + "number": 20, + "title": "中国银发经济", + "url": "https://github.com/666ghj/MiroFish/issues/20", + "state": "closed", + "created_at": "2026-01-14T08:28:10Z", + "updated_at": "2026-01-14T08:28:37Z", + "closed_at": "2026-01-14T08:28:37Z", + "labels": [], + "author": "XenaQi", + "body_excerpt": "[民政部等8部门联合印发.pdf](https://github.com/user-attachments/files/24608574/8.pdf)", + "comment_count": 1, + "recent_comments": [ + { + "author": "XenaQi", + "created_at": "2026-01-14T08:28:33Z", + "updated_at": "2026-01-14T08:28:33Z", + "url": "https://github.com/666ghj/MiroFish/issues/20#issuecomment-3748395648", + "body_excerpt": "预测养老机构发展" + } + ], + "triage_status": "untracked", + "summary": "[民政部等8部门联合印发.pdf](https://github.com/user-attachments/files/24608574/8.pdf)", + "coverage_status": "untracked", + "coverage_summary": "[民政部等8部门联合印发.pdf](https://github.com/user-attachments/files/24608574/8.pdf)", + "fork_issue_mirrored": true, + "fork_issue_number": 85, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/85" + }, + { + "number": 17, + "title": "多次仿真 + 元报告共识,提升预测结果的可信度", + "url": "https://github.com/666ghj/MiroFish/issues/17", + "state": "open", + "created_at": "2026-01-06T09:07:51Z", + "updated_at": "2026-01-06T09:07:51Z", + "closed_at": null, + "labels": [], + "author": "tt-a1i", + "body_excerpt": "## 背景 我觉得这个项目做的很棒,很有意思,这两天跑了几轮,发现单次仿真的结果可能比较受模型偏好的影响?——同样的输入材料,换个模型,结论可能会有差异。这让我在想:有没有办法让预测结果更稳定、更可信? ## 想法 核心思路是:**同一份材料跑多次仿真,然后在报告阶段做\"共识提取 + 分歧分析\"**。 具体来说: 1. **固定 Step1/2**:图谱构建和环境配置只跑一次,作为多次仿真的共享基础 2. **Step3 跑 2-3 次**:每次可以用不同的随机种子,或者稍微调整一些参数(比如 temperature) 3. **Step4 做元报告**: - 把\"多次都出现的结论\"作为**共识**(可信度高) - 把\"只在某一次出现的走向\"列为**分歧情景**,并尝试分析触发条件 4. **不同步骤用不同模型**: - 结构化抽取(本体/实体/配置 JSON)用格式稳定、指令遵从强的模…", + "comment_count": 0, + "recent_comments": [], + "local_coverage": { + "number": 17, + "status": "tracked", + "summary": "Tracked locally in beads as `mirofish-77h`: a future multi-run consensus workflow should reuse a shared Step 1/2 setup, orchestrate repeated Step 3 runs, and synthesize a consensus/divergence report in Step 4.", + "local_refs": [ + ".beads/issues.jsonl", + "docs/upstream-triage.md" + ], + "validation": [ + "tracking only" + ] + }, + "local_status": "tracked", + "local_summary": "Tracked locally in beads as `mirofish-77h`: a future multi-run consensus workflow should reuse a shared Step 1/2 setup, orchestrate repeated Step 3 runs, and synthesize a consensus/divergence report in Step 4.", + "triage_status": "tracked", + "summary": "Tracked locally in beads as `mirofish-77h`: a future multi-run consensus workflow should reuse a shared Step 1/2 setup, orchestrate repeated Step 3 runs, and synthesize a consensus/divergence report in Step 4.", + "coverage_status": "tracked", + "coverage_summary": "Tracked locally in beads as `mirofish-77h`: a future multi-run consensus workflow should reuse a shared Step 1/2 setup, orchestrate repeated Step 3 runs, and synthesize a consensus/divergence report in Step 4.", + "fork_issue_mirrored": true, + "fork_issue_number": 37, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/37" + }, + { + "number": 16, + "title": "Ignore (created by mistake)", + "url": "https://github.com/666ghj/MiroFish/issues/16", + "state": "closed", + "created_at": "2026-01-06T08:51:20Z", + "updated_at": "2026-01-06T08:55:04Z", + "closed_at": "2026-01-06T08:53:01Z", + "labels": [], + "author": "tt-a1i", + "body_excerpt": "", + "comment_count": 0, + "recent_comments": [], + "triage_status": "untracked", + "summary": "", + "coverage_status": "untracked", + "coverage_summary": "", + "fork_issue_mirrored": true, + "fork_issue_number": 86, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/86" + }, + { + "number": 14, + "title": "Frontend doesn't show error when simulation fails", + "url": "https://github.com/666ghj/MiroFish/issues/14", + "state": "open", + "created_at": "2026-01-05T11:43:44Z", + "updated_at": "2026-01-06T06:15:22Z", + "closed_at": null, + "labels": [], + "author": "tt-a1i", + "body_excerpt": "## What happened When a simulation fails (process crashes, missing deps, etc.), the frontend just sits there showing \"running\" status with no error message. The backend logs the error correctly and returns `runner_status: 'failed'` with an error message, but the UI never picks it up. Ran into this while testing locally - took me a while to figure out why nothing was happening. ## Steps to reprodu…", + "comment_count": 1, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-01-06T06:15:22Z", + "updated_at": "2026-01-06T06:15:22Z", + "url": "https://github.com/666ghj/MiroFish/issues/14#issuecomment-3713269669", + "body_excerpt": "I'll investigate this, and you're also welcome to submit a PR first." + } + ], + "local_coverage": { + "number": 14, + "status": "covered", + "summary": "Step 3 now stops polling and surfaces backend failure text when the simulation runner reports `failed`, so the UI no longer hangs indefinitely.", + "local_refs": [ + "frontend/src/components/Step3Simulation.vue", + "frontend/tests/errors.test.mjs" + ], + "validation": [ + "frontend: npm test", + "frontend: npm run build" + ] + }, + "local_status": "covered", + "local_summary": "Step 3 now stops polling and surfaces backend failure text when the simulation runner reports `failed`, so the UI no longer hangs indefinitely.", + "triage_status": "covered", + "summary": "Step 3 now stops polling and surfaces backend failure text when the simulation runner reports `failed`, so the UI no longer hangs indefinitely.", + "coverage_status": "covered", + "coverage_summary": "Step 3 now stops polling and surfaces backend failure text when the simulation runner reports `failed`, so the UI no longer hangs indefinitely.", + "fork_issue_mirrored": true, + "fork_issue_number": 38, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/38" + }, + { + "number": 11, + "title": "请问前后端服务起来后 为什么一段时间不用后 自动停止服务", + "url": "https://github.com/666ghj/MiroFish/issues/11", + "state": "closed", + "created_at": "2025-12-30T01:33:19Z", + "updated_at": "2025-12-30T07:23:39Z", + "closed_at": "2025-12-30T07:23:39Z", + "labels": [], + "author": "zkkkkkk72", + "body_excerpt": "", + "comment_count": 1, + "recent_comments": [ + { + "author": "zkkkkkk72", + "created_at": "2025-12-30T07:23:36Z", + "updated_at": "2025-12-30T07:23:36Z", + "url": "https://github.com/666ghj/MiroFish/issues/11#issuecomment-3698520490", + "body_excerpt": "已解决" + } + ], + "triage_status": "untracked", + "summary": "", + "coverage_status": "untracked", + "coverage_summary": "", + "fork_issue_mirrored": true, + "fork_issue_number": 87, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/87" + } + ], + "pull_requests": [ + { + "number": 144, + "title": "feat(kg): add dual-mode knowledge graph support", + "url": "https://github.com/666ghj/MiroFish/pull/144", + "state": "open", + "created_at": "2026-03-11T14:39:08Z", + "updated_at": "2026-03-12T03:22:22Z", + "closed_at": null, + "merged_at": null, + "head": "feat/local-knowledge-graph", + "head_ref_name": "feat/local-knowledge-graph", + "head_sha": "c3953e455227f5bc1ead6c89bb9c13aa49812d3b", + "head_repo": "huamingjie0815/MiroFish", + "head_clone_url": "https://github.com/huamingjie0815/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "Memory Layer", + "size:XXL" + ], + "author": "huamingjie0815", + "body_excerpt": "## Summary - Add kg_adapter for dual-mode knowledge graph (cloud/local) - Support switching between Zep Cloud and local Graphiti + Neo4j - Improve entity extraction and report agent robustness - Add test_kg_adapter.py with unit tests ## Test plan - [ ] Test cloud mode with Zep Cloud - [ ] Test local mode with Graphiti + Neo4j - [ ] Run unit tests 🤖 Generated with [Claude Code](https://claude.com/…", + "comment_count": 1, + "review_comment_count": 0, + "recent_comments": [ + { + "author": "huamingjie0815", + "created_at": "2026-03-12T03:22:22Z", + "updated_at": "2026-03-12T03:22:22Z", + "url": "https://github.com/666ghj/MiroFish/pull/144#issuecomment-4043640028", + "body_excerpt": "支持图谱的local 和cloud 双模式,local 是基于graphiti 改造,需要自己配置embedding模型 ,同时该提交增加一些功能优化,包括删除推演记录、导出报告、重新生成报告等功能,调整report_agent 的tool_call 的格式,从json改为xml 。" + } + ], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-144", + "mirror_ref": "origin/mirror/upstream-pr-144", + "local_coverage": { + "number": 144, + "status": "tracked", + "summary": "Tracked under beads issue `mirofish-8eg`: the dual-mode local knowledge graph branch is directionally aligned with the non-Zep backend request, but it is not safe to cherry-pick wholesale because it adds a large new adapter plus dependency stack on top of an older tree without current graph/simulation regression coverage.", + "local_refs": [ + ".beads/issues.jsonl", + "origin/mirror/upstream-pr-144" + ], + "validation": [ + "triage diff review", + "git diff --stat HEAD...upstream/pr-144" + ] + }, + "local_status": "tracked", + "local_summary": "Tracked under beads issue `mirofish-8eg`: the dual-mode local knowledge graph branch is directionally aligned with the non-Zep backend request, but it is not safe to cherry-pick wholesale because it adds a large new adapter plus dependency stack on top of an older tree without current graph/simulation regression coverage.", + "triage_status": "tracked", + "summary": "Tracked under beads issue `mirofish-8eg`: the dual-mode local knowledge graph branch is directionally aligned with the non-Zep backend request, but it is not safe to cherry-pick wholesale because it adds a large new adapter plus dependency stack on top of an older tree without current graph/simulation regression coverage.", + "coverage_status": "tracked", + "coverage_summary": "Tracked under beads issue `mirofish-8eg`: the dual-mode local knowledge graph branch is directionally aligned with the non-Zep backend request, but it is not safe to cherry-pick wholesale because it adds a large new adapter plus dependency stack on top of an older tree without current graph/simulation regression coverage.", + "local_review": { + "status": "tracked", + "summary": "Tracked under beads issue `mirofish-8eg`: the dual-mode local knowledge graph branch is directionally aligned with the non-Zep backend request, but it is not safe to cherry-pick wholesale because it adds a large new adapter plus dependency stack on top of an older tree without current graph/simulation regression coverage.", + "local_refs": [ + ".beads/issues.jsonl", + "origin/mirror/upstream-pr-144" + ], + "validation": [ + "triage diff review", + "git diff --stat HEAD...upstream/pr-144" + ], + "notes": null + } + }, + { + "number": 152, + "title": "feat(report): Zep 命名修复与导出 Markdown 功能", + "url": "https://github.com/666ghj/MiroFish/pull/152", + "state": "open", + "created_at": "2026-03-11T18:09:38Z", + "updated_at": "2026-03-12T02:15:37Z", + "closed_at": null, + "merged_at": null, + "head": "support-pascal-and-snake-case", + "head_ref_name": "support-pascal-and-snake-case", + "head_sha": "d04ce413711e33219a8a124da6d3e8e0102f8803", + "head_repo": "sx-tane/MiroFish", + "head_clone_url": "https://github.com/sx-tane/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "size:XS" + ], + "author": "sx-tane", + "body_excerpt": "## 概述 本 PR 包含以下改进: 1. **Zep 命名修复**:修复了 Zep API 实体/关系命名的格式校验错误(支持 PascalCase 和 snake_case)。 2. **新增功能**:报告生成步骤支持导出为 Markdown 格式,并采用了正式的 PDF 风格排版。 ## 修改详情 ### 后端 (Backend) - 在 `report_agent.py` 中改进了 `ReportManager.assemble_full_report` 方法,新增了包含 ID、模拟场景和时间戳的正式页眉。 - 添加了章节分隔符,显著提升了导出的 Markdown 文件的可读性。 ### 前端 (Frontend) - 在 `Step4Report.vue` 的报告页眉部分新增了“导出 MD”按钮。 - 在 `src/api/report.js` 中实现了 `downloadRe…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-152", + "mirror_ref": "origin/mirror/upstream-pr-152", + "local_coverage": { + "number": 152, + "status": "landed", + "summary": "Landed locally as repo-native subsets instead of a wholesale cherry-pick: ontology generation and graph build submission now normalize type names to Zep-compatible PascalCase / SCREAMING_SNAKE_CASE conventions, and TaskManager state now persists to disk so graph/report task status survives backend restarts.", + "local_refs": [ + "backend/app/services/ontology_generator.py", + "backend/tests/test_ontology_generator.py", + "backend/app/services/graph_builder.py", + "backend/tests/test_graph_builder.py", + "backend/app/models/task.py", + "backend/tests/test_task_manager.py", + "origin/mirror/upstream-pr-152", + ".beads/issues.jsonl" + ], + "validation": [ + "cd backend && uv run pytest -q tests/test_ontology_generator.py", + "python3 -m compileall backend/app/services/ontology_generator.py backend/tests/test_ontology_generator.py", + "cd backend && uv run pytest -q tests/test_graph_builder.py", + "python3 -m compileall backend/app/services/graph_builder.py", + "uv run --project backend pytest -q backend/tests/test_task_manager.py backend/tests/test_backend_localized_errors.py", + "python3 -m compileall backend/app/models/task.py backend/tests/test_task_manager.py" + ] + }, + "local_status": "landed", + "local_summary": "Landed locally as repo-native subsets instead of a wholesale cherry-pick: ontology generation and graph build submission now normalize type names to Zep-compatible PascalCase / SCREAMING_SNAKE_CASE conventions, and TaskManager state now persists to disk so graph/report task status survives backend restarts.", + "triage_status": "landed", + "summary": "Landed locally as repo-native subsets instead of a wholesale cherry-pick: ontology generation and graph build submission now normalize type names to Zep-compatible PascalCase / SCREAMING_SNAKE_CASE conventions, and TaskManager state now persists to disk so graph/report task status survives backend restarts.", + "coverage_status": "landed", + "coverage_summary": "Landed locally as repo-native subsets instead of a wholesale cherry-pick: ontology generation and graph build submission now normalize type names to Zep-compatible PascalCase / SCREAMING_SNAKE_CASE conventions, and TaskManager state now persists to disk so graph/report task status survives backend restarts.", + "local_review": { + "status": "landed", + "summary": "Landed locally as repo-native subsets instead of a wholesale cherry-pick: ontology generation and graph build submission now normalize type names to Zep-compatible PascalCase / SCREAMING_SNAKE_CASE conventions, and TaskManager state now persists to disk so graph/report task status survives backend restarts.", + "local_refs": [ + "backend/app/services/ontology_generator.py", + "backend/tests/test_ontology_generator.py", + "backend/app/services/graph_builder.py", + "backend/tests/test_graph_builder.py", + "backend/app/models/task.py", + "backend/tests/test_task_manager.py", + "origin/mirror/upstream-pr-152", + ".beads/issues.jsonl" + ], + "validation": [ + "cd backend && uv run pytest -q tests/test_ontology_generator.py", + "python3 -m compileall backend/app/services/ontology_generator.py backend/tests/test_ontology_generator.py", + "cd backend && uv run pytest -q tests/test_graph_builder.py", + "python3 -m compileall backend/app/services/graph_builder.py", + "uv run --project backend pytest -q backend/tests/test_task_manager.py backend/tests/test_backend_localized_errors.py", + "python3 -m compileall backend/app/models/task.py backend/tests/test_task_manager.py" + ], + "notes": null + } + }, + { + "number": 155, + "title": "chore: backend, frontend, i18n (en/zh), and Docker updates", + "url": "https://github.com/666ghj/MiroFish/pull/155", + "state": "open", + "created_at": "2026-03-12T00:47:20Z", + "updated_at": "2026-03-12T00:56:30Z", + "closed_at": null, + "merged_at": null, + "head": "english-trans", + "head_ref_name": "english-trans", + "head_sha": "dac678d45f3a3f7b167c6b22f22bf5d21dda1cab", + "head_repo": "kaeli-byte/MiroFish", + "head_clone_url": "https://github.com/kaeli-byte/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "size:XXL" + ], + "author": "kaeli-byte", + "body_excerpt": "Made-with: Cursor", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-155", + "mirror_ref": "origin/mirror/upstream-pr-155", + "local_coverage": { + "number": 155, + "status": "tracked", + "summary": "Tracked under beads issue `mirofish-2ul1`: the combined backend/frontend/i18n/docker sweep is mirrored into `origin/mirror/upstream-pr-155`, but it rewrites 59 files on an older tree and is not safe to cherry-pick wholesale on top of the current repo-native OpenAI-compat, tests, docs, and partial i18n work.", + "local_refs": [ + ".beads/issues.jsonl", + "origin/mirror/upstream-pr-155", + "docs/upstream-triage.md", + "scripts/sync_upstream_github.py", + "tests/test_sync_upstream_github.py" + ], + "validation": [ + "git diff --stat upstream/main...origin/mirror/upstream-pr-155", + "python3 -m unittest tests.test_sync_upstream_github" + ] + }, + "local_status": "tracked", + "local_summary": "Tracked under beads issue `mirofish-2ul1`: the combined backend/frontend/i18n/docker sweep is mirrored into `origin/mirror/upstream-pr-155`, but it rewrites 59 files on an older tree and is not safe to cherry-pick wholesale on top of the current repo-native OpenAI-compat, tests, docs, and partial i18n work.", + "triage_status": "tracked", + "summary": "Tracked under beads issue `mirofish-2ul1`: the combined backend/frontend/i18n/docker sweep is mirrored into `origin/mirror/upstream-pr-155`, but it rewrites 59 files on an older tree and is not safe to cherry-pick wholesale on top of the current repo-native OpenAI-compat, tests, docs, and partial i18n work.", + "coverage_status": "tracked", + "coverage_summary": "Tracked under beads issue `mirofish-2ul1`: the combined backend/frontend/i18n/docker sweep is mirrored into `origin/mirror/upstream-pr-155`, but it rewrites 59 files on an older tree and is not safe to cherry-pick wholesale on top of the current repo-native OpenAI-compat, tests, docs, and partial i18n work.", + "local_review": { + "status": "tracked", + "summary": "Tracked under beads issue `mirofish-2ul1`: the combined backend/frontend/i18n/docker sweep is mirrored into `origin/mirror/upstream-pr-155`, but it rewrites 59 files on an older tree and is not safe to cherry-pick wholesale on top of the current repo-native OpenAI-compat, tests, docs, and partial i18n work.", + "local_refs": [ + ".beads/issues.jsonl", + "origin/mirror/upstream-pr-155", + "docs/upstream-triage.md", + "scripts/sync_upstream_github.py", + "tests/test_sync_upstream_github.py" + ], + "validation": [ + "git diff --stat upstream/main...origin/mirror/upstream-pr-155", + "python3 -m unittest tests.test_sync_upstream_github" + ], + "notes": null + } + }, + { + "number": 151, + "title": "Fix silent data loss when platform defaults to reddit for Twitter-only simulations", + "url": "https://github.com/666ghj/MiroFish/pull/151", + "state": "open", + "created_at": "2026-03-11T17:49:51Z", + "updated_at": "2026-03-11T17:49:59Z", + "closed_at": null, + "merged_at": null, + "head": "fix/platform-default-reddit-silent-failure", + "head_ref_name": "fix/platform-default-reddit-silent-failure", + "head_sha": "18ba979c8da4cbbd25c2ce9615d5ad65f242b718", + "head_repo": "karesansui-u/MiroFish", + "head_clone_url": "https://github.com/karesansui-u/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "size:M" + ], + "author": "karesansui-u", + "body_excerpt": "## Summary - API retrieval endpoints (`/profiles`, `/profiles/realtime`, `/posts`, `/comments`) hardcoded `'reddit'` as the default platform - When a Twitter-only simulation was run (`enable_reddit=false`), these APIs silently returned empty results because they looked for `reddit_simulation.db` / `reddit_profiles.json` which did not exist - Frontend also hardcoded `'reddit'` in Vue components an…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-151", + "mirror_ref": "origin/mirror/upstream-pr-151", + "local_coverage": { + "number": 151, + "status": "landed", + "summary": "Landed locally before the upstream PR opened: Twitter-only simulations now infer the active platform instead of silently defaulting to Reddit in retrieval APIs and profile loading, matching the intent of upstream PR #151.", + "local_refs": [ + "backend/app/api/simulation.py", + "backend/app/services/simulation_manager.py", + "backend/tests/test_simulation_service_i18n.py", + "backend/tests/test_simulation_api_i18n.py", + "origin/mirror/upstream-pr-151" + ], + "validation": [ + "cd backend && uv run pytest -q tests/test_simulation_service_i18n.py tests/test_simulation_api_i18n.py", + "python3 -m compileall backend/app/services/simulation_manager.py backend/app/api/simulation.py" + ] + }, + "local_status": "landed", + "local_summary": "Landed locally before the upstream PR opened: Twitter-only simulations now infer the active platform instead of silently defaulting to Reddit in retrieval APIs and profile loading, matching the intent of upstream PR #151.", + "triage_status": "landed", + "summary": "Landed locally before the upstream PR opened: Twitter-only simulations now infer the active platform instead of silently defaulting to Reddit in retrieval APIs and profile loading, matching the intent of upstream PR #151.", + "coverage_status": "landed", + "coverage_summary": "Landed locally before the upstream PR opened: Twitter-only simulations now infer the active platform instead of silently defaulting to Reddit in retrieval APIs and profile loading, matching the intent of upstream PR #151.", + "local_review": { + "status": "landed", + "summary": "Landed locally before the upstream PR opened: Twitter-only simulations now infer the active platform instead of silently defaulting to Reddit in retrieval APIs and profile loading, matching the intent of upstream PR #151.", + "local_refs": [ + "backend/app/api/simulation.py", + "backend/app/services/simulation_manager.py", + "backend/tests/test_simulation_service_i18n.py", + "backend/tests/test_simulation_api_i18n.py", + "origin/mirror/upstream-pr-151" + ], + "validation": [ + "cd backend && uv run pytest -q tests/test_simulation_service_i18n.py tests/test_simulation_api_i18n.py", + "python3 -m compileall backend/app/services/simulation_manager.py backend/app/api/simulation.py" + ], + "notes": null + } + }, + { + "number": 147, + "title": "feat: Russian localization (Русская локализация)", + "url": "https://github.com/666ghj/MiroFish/pull/147", + "state": "open", + "created_at": "2026-03-11T16:17:18Z", + "updated_at": "2026-03-11T16:19:27Z", + "closed_at": null, + "merged_at": null, + "head": "russian-localization", + "head_ref_name": "russian-localization", + "head_sha": "cdfece4e116a03d2631e1a70a41e0d86fc4473e4", + "head_repo": "notageek88/MiroFish", + "head_clone_url": "https://github.com/notageek88/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "documentation", + "size:XXL" + ], + "author": "notageek88", + "body_excerpt": "## 🇷🇺 Russian Localization This PR adds a complete Russian translation of MiroFish: ### Changes: - **15 Vue components** — all UI labels, buttons, placeholders, error messages, and tooltips translated from Chinese to Russian - **README-RU.md** — full Russian documentation with quick start guide - Translation files are in `frontend-ru/src/` (ready to merge into `frontend/src/` when approved) - LLM…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-147", + "mirror_ref": "origin/mirror/upstream-pr-147", + "local_coverage": { + "number": 147, + "status": "partial", + "summary": "Safe subset landed locally: added a repo-native `README-RU.md` plus language cross-links, but the full branch is not safe to cherry-pick because it replaces large frontend/backend sections and drops current local tooling, tests, and upstream-triage assets.", + "local_refs": [ + "README.md", + "README-EN.md", + "README-JA.md", + "README-KO.md", + "README-RU.md", + "origin/mirror/upstream-pr-147" + ], + "validation": [ + "documentation review", + "git diff --stat HEAD..mirror/upstream-pr-147" + ] + }, + "local_status": "partial", + "local_summary": "Safe subset landed locally: added a repo-native `README-RU.md` plus language cross-links, but the full branch is not safe to cherry-pick because it replaces large frontend/backend sections and drops current local tooling, tests, and upstream-triage assets.", + "triage_status": "partial", + "summary": "Safe subset landed locally: added a repo-native `README-RU.md` plus language cross-links, but the full branch is not safe to cherry-pick because it replaces large frontend/backend sections and drops current local tooling, tests, and upstream-triage assets.", + "coverage_status": "partial", + "coverage_summary": "Safe subset landed locally: added a repo-native `README-RU.md` plus language cross-links, but the full branch is not safe to cherry-pick because it replaces large frontend/backend sections and drops current local tooling, tests, and upstream-triage assets.", + "local_review": { + "status": "partial", + "summary": "Safe subset landed locally: added a repo-native `README-RU.md` plus language cross-links, but the full branch is not safe to cherry-pick because it replaces large frontend/backend sections and drops current local tooling, tests, and upstream-triage assets.", + "local_refs": [ + "README.md", + "README-EN.md", + "README-JA.md", + "README-KO.md", + "README-RU.md", + "origin/mirror/upstream-pr-147" + ], + "validation": [ + "documentation review", + "git diff --stat HEAD..mirror/upstream-pr-147" + ], + "notes": null + } + }, + { + "number": 141, + "title": "feat: add entity deduplication after graph building", + "url": "https://github.com/666ghj/MiroFish/pull/141", + "state": "open", + "created_at": "2026-03-11T13:38:51Z", + "updated_at": "2026-03-11T14:48:14Z", + "closed_at": null, + "merged_at": null, + "head": "feature/entity-deduplication", + "head_ref_name": "feature/entity-deduplication", + "head_sha": "a728540a258804476a7058e21023bf94dfb48026", + "head_repo": "Stayfoool/MiroFish", + "head_clone_url": "https://github.com/Stayfoool/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "enhancement", + "size:XL" + ], + "author": "Stayfoool", + "body_excerpt": "Hi @666ghj I noticed that during graph building, Zep sometimes creates duplicate entity nodes for the same real-world entity (e.g. \"特朗普\" and \"美国总统特朗普\" appear as separate nodes). This affects the accuracy of the knowledge graph. This PR adds an automatic entity deduplication step after graph building, using name similarity pre-filtering + type compatibility check + LLM confirmation to identify and…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-141", + "mirror_ref": "origin/mirror/upstream-pr-141", + "local_coverage": { + "number": 141, + "status": "not_safe", + "summary": "Not safe to cherry-pick: the entity-deduplication branch rewinds large portions of the current tree (tooling, tests, i18n, OpenAI-compatible docs/config) while adding a large graph mutation feature, so it needs a repo-native reimplementation with targeted regression coverage instead of a blind merge.", + "local_refs": [ + "origin/mirror/upstream-pr-141", + ".beads/issues.jsonl" + ], + "validation": [ + "triage diff review", + "git diff --stat HEAD..upstream/pr/141" + ] + }, + "local_status": "not_safe", + "local_summary": "Not safe to cherry-pick: the entity-deduplication branch rewinds large portions of the current tree (tooling, tests, i18n, OpenAI-compatible docs/config) while adding a large graph mutation feature, so it needs a repo-native reimplementation with targeted regression coverage instead of a blind merge.", + "triage_status": "not_safe", + "summary": "Not safe to cherry-pick: the entity-deduplication branch rewinds large portions of the current tree (tooling, tests, i18n, OpenAI-compatible docs/config) while adding a large graph mutation feature, so it needs a repo-native reimplementation with targeted regression coverage instead of a blind merge.", + "coverage_status": "not_safe", + "coverage_summary": "Not safe to cherry-pick: the entity-deduplication branch rewinds large portions of the current tree (tooling, tests, i18n, OpenAI-compatible docs/config) while adding a large graph mutation feature, so it needs a repo-native reimplementation with targeted regression coverage instead of a blind merge.", + "local_review": { + "status": "not_safe", + "summary": "Not safe to cherry-pick: the entity-deduplication branch rewinds large portions of the current tree (tooling, tests, i18n, OpenAI-compatible docs/config) while adding a large graph mutation feature, so it needs a repo-native reimplementation with targeted regression coverage instead of a blind merge.", + "local_refs": [ + "origin/mirror/upstream-pr-141", + ".beads/issues.jsonl" + ], + "validation": [ + "triage diff review", + "git diff --stat HEAD..upstream/pr/141" + ], + "notes": null + } + }, + { + "number": 143, + "title": "docs: fix README alt text URL encoding", + "url": "https://github.com/666ghj/MiroFish/pull/143", + "state": "open", + "created_at": "2026-03-11T14:37:42Z", + "updated_at": "2026-03-11T14:39:04Z", + "closed_at": null, + "merged_at": null, + "head": "docs/urlEncoding", + "head_ref_name": "docs/urlEncoding", + "head_sha": "ecf6a84a3b471583cb5a436f9ef83c72e24c65b2", + "head_repo": "fishwww-ww/MiroFish", + "head_clone_url": "https://github.com/fishwww-ww/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "documentation", + "size:XS" + ], + "author": "fishwww-ww", + "body_excerpt": "## Summary Fix the Shanda image alt text in README.md by changing 666ghj%2MiroFish to 666ghj%2FMiroFish. ## Details 666ghj%2MiroFish is not a valid URL-encoded representation, so it cannot be decoded correctly. Using 666ghj%2FMiroFish correctly encodes the slash and can be properly decoded to 666ghj/ MiroFish. ## Impact Documentation-only change. No code or runtime behavior is affected.", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-143", + "mirror_ref": "origin/mirror/upstream-pr-143", + "local_coverage": { + "number": 143, + "status": "landed", + "summary": "Landed locally as a repo-native docs cleanup: the Shanda logo alt text now uses the correct URL-encoded `666ghj%2FMiroFish` slug across all README variants, not just the primary Chinese README.", + "local_refs": [ + "README.md", + "README-EN.md", + "README-JA.md", + "README-KO.md", + "origin/mirror/upstream-pr-143" + ], + "validation": [ + "documentation review", + "rg -n \"666ghj%2MiroFish|666ghj%2FMiroFish\" README.md README-EN.md README-JA.md README-KO.md" + ] + }, + "local_status": "landed", + "local_summary": "Landed locally as a repo-native docs cleanup: the Shanda logo alt text now uses the correct URL-encoded `666ghj%2FMiroFish` slug across all README variants, not just the primary Chinese README.", + "triage_status": "landed", + "summary": "Landed locally as a repo-native docs cleanup: the Shanda logo alt text now uses the correct URL-encoded `666ghj%2FMiroFish` slug across all README variants, not just the primary Chinese README.", + "coverage_status": "landed", + "coverage_summary": "Landed locally as a repo-native docs cleanup: the Shanda logo alt text now uses the correct URL-encoded `666ghj%2FMiroFish` slug across all README variants, not just the primary Chinese README.", + "local_review": { + "status": "landed", + "summary": "Landed locally as a repo-native docs cleanup: the Shanda logo alt text now uses the correct URL-encoded `666ghj%2FMiroFish` slug across all README variants, not just the primary Chinese README.", + "local_refs": [ + "README.md", + "README-EN.md", + "README-JA.md", + "README-KO.md", + "origin/mirror/upstream-pr-143" + ], + "validation": [ + "documentation review", + "rg -n \"666ghj%2MiroFish|666ghj%2FMiroFish\" README.md README-EN.md README-JA.md README-KO.md" + ], + "notes": null + } + }, + { + "number": 127, + "title": "Fix potential crash in LLMClient when content is None", + "url": "https://github.com/666ghj/MiroFish/pull/127", + "state": "closed", + "created_at": "2026-03-10T22:43:33Z", + "updated_at": "2026-03-11T07:30:24Z", + "closed_at": "2026-03-11T07:24:30Z", + "merged_at": null, + "head": "fix/llm-client-none-content", + "head_ref_name": "fix/llm-client-none-content", + "head_sha": "f6fe00a3643f7d0f2b0db29343181a1d26a48da6", + "head_repo": "sjhddh/MiroFish", + "head_clone_url": "https://github.com/sjhddh/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "LLM API", + "size:XS" + ], + "author": "sjhddh", + "body_excerpt": "Added `if content is None: return \"\"` in `backend/app/utils/llm_client.py` to prevent `re.sub` TypeError. --- *Automated PR created by OpenClaw daily-pr routine.*", + "comment_count": 2, + "review_comment_count": 0, + "recent_comments": [ + { + "author": "sjhddh", + "created_at": "2026-03-11T07:24:30Z", + "updated_at": "2026-03-11T07:24:30Z", + "url": "https://github.com/666ghj/MiroFish/pull/127#issuecomment-4037053433", + "body_excerpt": "Closing this PR as it was submitted with an incorrect Git author configuration. Apologies for the noise!" + }, + { + "author": "sjhddh", + "created_at": "2026-03-11T07:29:04Z", + "updated_at": "2026-03-11T07:30:24Z", + "url": "https://github.com/666ghj/MiroFish/pull/127#issuecomment-4037075767", + "body_excerpt": "Reopened and force-pushed with the correct Git author identity (`sjhddh`). Thanks for your patience!" + } + ], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-127", + "mirror_ref": "origin/mirror/upstream-pr-127", + "local_coverage": { + "number": 127, + "status": "landed", + "summary": "Landed locally: `LLMClient.chat()` already coerces `None` completion content to an empty string before post-processing, and `backend/tests/test_llm_client.py` covers the regression explicitly.", + "local_refs": [ + "backend/app/utils/llm_client.py", + "backend/tests/test_llm_client.py", + "origin/mirror/upstream-pr-127" + ], + "validation": [ + "cd backend && uv run pytest -q tests/test_llm_client.py -k none" + ] + }, + "local_status": "landed", + "local_summary": "Landed locally: `LLMClient.chat()` already coerces `None` completion content to an empty string before post-processing, and `backend/tests/test_llm_client.py` covers the regression explicitly.", + "triage_status": "landed", + "summary": "Landed locally: `LLMClient.chat()` already coerces `None` completion content to an empty string before post-processing, and `backend/tests/test_llm_client.py` covers the regression explicitly.", + "coverage_status": "landed", + "coverage_summary": "Landed locally: `LLMClient.chat()` already coerces `None` completion content to an empty string before post-processing, and `backend/tests/test_llm_client.py` covers the regression explicitly.", + "local_review": { + "status": "landed", + "summary": "Landed locally: `LLMClient.chat()` already coerces `None` completion content to an empty string before post-processing, and `backend/tests/test_llm_client.py` covers the regression explicitly.", + "local_refs": [ + "backend/app/utils/llm_client.py", + "backend/tests/test_llm_client.py", + "origin/mirror/upstream-pr-127" + ], + "validation": [ + "cd backend && uv run pytest -q tests/test_llm_client.py -k none" + ], + "notes": null + } + }, + { + "number": 120, + "title": "fix: 修复subsystems目录下neo4j_client导入路径错误; feat: 添加TODO.md开发规划文档", + "url": "https://github.com/666ghj/MiroFish/pull/120", + "state": "closed", + "created_at": "2026-03-10T15:41:04Z", + "updated_at": "2026-03-11T07:09:15Z", + "closed_at": "2026-03-11T07:09:15Z", + "merged_at": null, + "head": "main", + "head_ref_name": "main", + "head_sha": "d07559429ff80b97a9a6276d4f28fddf7aed921b", + "head_repo": null, + "head_clone_url": null, + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "size:XXL" + ], + "author": "28764116", + "body_excerpt": "新增neo4j 板块", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-120", + "mirror_ref": "origin/mirror/upstream-pr-120", + "triage_status": "untracked", + "summary": "新增neo4j 板块", + "coverage_status": "untracked", + "coverage_summary": "新增neo4j 板块", + "local_review": { + "status": "unreviewed", + "summary": "新增neo4j 板块", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 105, + "title": "fix: security improvements and error handling fixes", + "url": "https://github.com/666ghj/MiroFish/pull/105", + "state": "open", + "created_at": "2026-03-09T11:57:58Z", + "updated_at": "2026-03-11T05:48:53Z", + "closed_at": null, + "merged_at": null, + "head": "fix/security-improvements", + "head_ref_name": "fix/security-improvements", + "head_sha": "a33adf3e1f92e456f1134befd8745e0bad09034a", + "head_repo": "hobostay/MiroFish", + "head_clone_url": "https://github.com/hobostay/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "documentation", + "size:L" + ], + "author": "hobostay", + "body_excerpt": "## 问题概述 这个PR修复了项目中发现的多个安全问题和代码质量问题。 ## 安全修复 1. **硬编码的SECRET_KEY** - `backend/app/config.py` - 之前:使用硬编码的`'mirofish-secret-key'`作为默认值 - 现在:如果未设置环境变量,会生成随机密钥并发出警告 2. **DEBUG模式默认为True** - `backend/app/config.py` - 之前:`DEBUG`默认为`True` - 现在:`DEBUG`默认为`False`,生产环境更安全 3. **CORS配置允许所有来源** - `backend/app/__init__.py` - 之前:`CORS(app, resources={r\"/api/*\": {\"origins\": \"*\"}})` - 现在:通过环境变量`CORS_ALLOWED_ORIGINS…", + "comment_count": 2, + "review_comment_count": 0, + "recent_comments": [ + { + "author": "JasonOA888", + "created_at": "2026-03-09T17:00:24Z", + "updated_at": "2026-03-09T17:00:24Z", + "url": "https://github.com/666ghj/MiroFish/pull/105#issuecomment-4025290222", + "body_excerpt": "## 代码审查反馈 优秀的PR!这些安全修复非常关键,特别是生产环境部署时。 ### 几个建议: 1. **SECRET_KEY随机生成** - 建议添加日志记录生成的key,方便调试但不要泄露到错误响应中 2. **CORS配置** - 考虑添加`CORS_ALLOW_METHODS`和`CORS_ALLOW_HEADERS`配置,提供更细粒度的控制 3. **error_handler.py** - 建议添加自定义异常类型,让API可以抛出特定错误而不是通用Except…" + }, + { + "author": "hobostay", + "created_at": "2026-03-11T05:48:53Z", + "updated_at": "2026-03-11T05:48:53Z", + "url": "https://github.com/666ghj/MiroFish/pull/105#issuecomment-4036667034", + "body_excerpt": "@JasonOA888 感谢您的审查反馈!我已经根据您的建议完成了所有修改: **✅ 1. SECRET_KEY随机生成** - 使用 `secrets.token_hex(32)` 生成随机密钥 - 添加日志记录生成的key(方便调试) - 密钥只记录到日志,不会泄露到API错误响应中 **✅ 2. CORS细粒度配置** - 新增 `CORS_ALLOW_METHODS` 环境变量(默认:GET,POST,PUT,DELETE,OPTIONS) - 新增 `CORS_A…" + } + ], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-105", + "mirror_ref": "origin/mirror/upstream-pr-105", + "local_coverage": { + "number": 105, + "status": "landed", + "summary": "Landed locally: backend security/config hardening now includes env-driven CORS controls with a localhost-only default allowlist, `DEBUG=False` by default, and generated fallback `SECRET_KEY` behavior." + }, + "local_status": "landed", + "local_summary": "Landed locally: backend security/config hardening now includes env-driven CORS controls with a localhost-only default allowlist, `DEBUG=False` by default, and generated fallback `SECRET_KEY` behavior.", + "triage_status": "landed", + "summary": "Landed locally: backend security/config hardening now includes env-driven CORS controls with a localhost-only default allowlist, `DEBUG=False` by default, and generated fallback `SECRET_KEY` behavior.", + "coverage_status": "landed", + "coverage_summary": "Landed locally: backend security/config hardening now includes env-driven CORS controls with a localhost-only default allowlist, `DEBUG=False` by default, and generated fallback `SECRET_KEY` behavior.", + "local_review": { + "status": "landed", + "summary": "Landed locally: backend security/config hardening now includes env-driven CORS controls with a localhost-only default allowlist, `DEBUG=False` by default, and generated fallback `SECRET_KEY` behavior.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 132, + "title": "docs:add simple system architecture part for README-EN.md & README.md", + "url": "https://github.com/666ghj/MiroFish/pull/132", + "state": "open", + "created_at": "2026-03-11T05:45:53Z", + "updated_at": "2026-03-11T05:47:00Z", + "closed_at": null, + "merged_at": null, + "head": "docs/add-sys-architecture-part", + "head_ref_name": "docs/add-sys-architecture-part", + "head_sha": "a9d1c4839a23e6027b49d89af77728ab32cda167", + "head_repo": "Noblegasesgoo/MiroFish", + "head_clone_url": "https://github.com/Noblegasesgoo/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "documentation", + "size:L" + ], + "author": "Noblegasesgoo", + "body_excerpt": "## PR Title docs(readme): simplify system architecture section to Layer Breakdown + Project Code Structure Tree only ## Summary This PR simplifies the **System Architecture** section in both Chinese and English README files by keeping only two high-signal sections: - **Layer Breakdown** - **Project Code Structure Tree** The previously added overall architecture diagram and related agent-intro blo…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-132", + "mirror_ref": "origin/mirror/upstream-pr-132", + "local_coverage": { + "number": 132, + "status": "landed", + "summary": "Landed locally: README architecture overview." + }, + "local_status": "landed", + "local_summary": "Landed locally: README architecture overview.", + "triage_status": "landed", + "summary": "Landed locally: README architecture overview.", + "coverage_status": "landed", + "coverage_summary": "Landed locally: README architecture overview.", + "local_review": { + "status": "landed", + "summary": "Landed locally: README architecture overview.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 131, + "title": "feat(graph_builder): add retry mechanism for Zep Cloud connection failures", + "url": "https://github.com/666ghj/MiroFish/pull/131", + "state": "open", + "created_at": "2026-03-11T05:42:07Z", + "updated_at": "2026-03-11T05:43:10Z", + "closed_at": null, + "merged_at": null, + "head": "feat/zep-retry-mechanism", + "head_ref_name": "feat/zep-retry-mechanism", + "head_sha": "264d2c8757c1b9e5aead7c873ffd29f4add8ad4a", + "head_repo": "EuanTop/MiroFish", + "head_clone_url": "https://github.com/EuanTop/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "enhancement", + "size:L" + ], + "author": "EuanTop", + "body_excerpt": "## Description Adds automatic retry mechanism to handle transient network errors when connecting to Zep Cloud API. This prevents graph build failures caused by temporary connection issues such as \"Connection reset by peer\" (errno 54). The retry logic uses exponential backoff (2s, 4s, 6s) and provides detailed progress feedback to users. ## Changes - Added retry logic (max 3 attempts) to `create_g…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-131", + "mirror_ref": "origin/mirror/upstream-pr-131", + "local_coverage": { + "number": 131, + "status": "landed", + "summary": "Safe subset landed locally: transient Zep failures now retry with bounded backoff." + }, + "local_status": "landed", + "local_summary": "Safe subset landed locally: transient Zep failures now retry with bounded backoff.", + "triage_status": "landed", + "summary": "Safe subset landed locally: transient Zep failures now retry with bounded backoff.", + "coverage_status": "landed", + "coverage_summary": "Safe subset landed locally: transient Zep failures now retry with bounded backoff.", + "local_review": { + "status": "landed", + "summary": "Safe subset landed locally: transient Zep failures now retry with bounded backoff.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 130, + "title": "docs: 添加贡献指南文档", + "url": "https://github.com/666ghj/MiroFish/pull/130", + "state": "open", + "created_at": "2026-03-11T04:24:56Z", + "updated_at": "2026-03-11T04:26:00Z", + "closed_at": null, + "merged_at": null, + "head": "docs/add-pr-guide", + "head_ref_name": "docs/add-pr-guide", + "head_sha": "32d0571fd7bd59fae341a98cc63f7991409d8a9b", + "head_repo": "M-Tlinqinming/MiroFish", + "head_clone_url": "https://github.com/M-Tlinqinming/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "documentation", + "size:M" + ], + "author": "M-Tlinqinming", + "body_excerpt": "", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-130", + "mirror_ref": "origin/mirror/upstream-pr-130", + "local_coverage": { + "number": 130, + "status": "landed", + "summary": "Landed locally: `CONTRIBUTING.md`." + }, + "local_status": "landed", + "local_summary": "Landed locally: `CONTRIBUTING.md`.", + "triage_status": "landed", + "summary": "Landed locally: `CONTRIBUTING.md`.", + "coverage_status": "landed", + "coverage_summary": "Landed locally: `CONTRIBUTING.md`.", + "local_review": { + "status": "landed", + "summary": "Landed locally: `CONTRIBUTING.md`.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 129, + "title": "fix(report_agent): handle API token overflow crash with context lengt…", + "url": "https://github.com/666ghj/MiroFish/pull/129", + "state": "open", + "created_at": "2026-03-11T02:55:33Z", + "updated_at": "2026-03-11T02:56:38Z", + "closed_at": null, + "merged_at": null, + "head": "fix/fix-priority-issues-mNNjT", + "head_ref_name": "fix/fix-priority-issues-mNNjT", + "head_sha": "20c830af12cecd6fa6f03e8c0aca98b40dd8c858", + "head_repo": "ai-x-builder/MiroFish", + "head_clone_url": "https://github.com/ai-x-builder/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "LLM API", + "size:M" + ], + "author": "ai-x-builder", + "body_excerpt": "Add error handling in LLMClient for context_length_exceeded errors with automatic message trimming and retry (fixes https://github.com/666ghj/MiroFish/issues/52) Add configurable LLM_MAX_TOKENS env variable (default 4096) so users with different models can set appropriate limits Add message history pruning in report agent ReACT loop to prevent unbounded context growth that causes token overflow I…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-129", + "mirror_ref": "origin/mirror/upstream-pr-129", + "local_coverage": { + "number": 129, + "status": "landed", + "summary": "Safe subset landed locally for context-length retry, configurable `LLM_MAX_TOKENS`, and report-agent history pruning." + }, + "local_status": "landed", + "local_summary": "Safe subset landed locally for context-length retry, configurable `LLM_MAX_TOKENS`, and report-agent history pruning.", + "triage_status": "landed", + "summary": "Safe subset landed locally for context-length retry, configurable `LLM_MAX_TOKENS`, and report-agent history pruning.", + "coverage_status": "landed", + "coverage_summary": "Safe subset landed locally for context-length retry, configurable `LLM_MAX_TOKENS`, and report-agent history pruning.", + "local_review": { + "status": "landed", + "summary": "Safe subset landed locally for context-length retry, configurable `LLM_MAX_TOKENS`, and report-agent history pruning.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 126, + "title": "feat: Add custom exceptions and enhanced config validation", + "url": "https://github.com/666ghj/MiroFish/pull/126", + "state": "open", + "created_at": "2026-03-10T22:12:22Z", + "updated_at": "2026-03-10T22:13:37Z", + "closed_at": null, + "merged_at": null, + "head": "feature/custom-exceptions-and-config-validation", + "head_ref_name": "feature/custom-exceptions-and-config-validation", + "head_sha": "e639bd629ef5ebc260459287a863566453d07317", + "head_repo": "ZaviQ7/MiroFish", + "head_clone_url": "https://github.com/ZaviQ7/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "enhancement", + "size:XXL" + ], + "author": "ZaviQ7", + "body_excerpt": "## Summary This PR improves the robustness of the MiroFish backend by implementing two key architectural improvements: ### 1. Custom Exception Hierarchy - Created a `MiroFishError` base class with error codes, severity levels, and HTTP status codes. - Added domain-specific exceptions for Configuration, Graphs, Simulations, and External APIs to replace generic Exception catches. ### 2. Enhanced Co…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-126", + "mirror_ref": "origin/mirror/upstream-pr-126", + "local_coverage": { + "number": 126, + "status": "landed", + "summary": "Safe subset landed locally for structured config validation and non-sensitive config summaries." + }, + "local_status": "landed", + "local_summary": "Safe subset landed locally for structured config validation and non-sensitive config summaries.", + "triage_status": "landed", + "summary": "Safe subset landed locally for structured config validation and non-sensitive config summaries.", + "coverage_status": "landed", + "coverage_summary": "Safe subset landed locally for structured config validation and non-sensitive config summaries.", + "local_review": { + "status": "landed", + "summary": "Safe subset landed locally for structured config validation and non-sensitive config summaries.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 125, + "title": "fix: improve new-project network error diagnostics", + "url": "https://github.com/666ghj/MiroFish/pull/125", + "state": "open", + "created_at": "2026-03-10T21:50:43Z", + "updated_at": "2026-03-10T21:51:51Z", + "closed_at": null, + "merged_at": null, + "head": "fix/issue-121", + "head_ref_name": "fix/issue-121", + "head_sha": "3cd206cfc291f7b7311d27cdd0382f24d582bf99", + "head_repo": "SergioChan/MiroFish", + "head_clone_url": "https://github.com/SergioChan/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "enhancement", + "size:S" + ], + "author": "SergioChan", + "body_excerpt": "## Summary Improve frontend error feedback when creating a new project so users can quickly diagnose \"Network Error\" and timeout failures instead of seeing a generic message. ## Changes - Added `formatProjectInitError` in `frontend/src/views/Process.vue` - Distinguish timeout errors and provide actionable hint (reduce file size / check model speed) - Distinguish network errors and show configured…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-125", + "mirror_ref": "origin/mirror/upstream-pr-125", + "local_coverage": { + "number": 125, + "status": "landed", + "summary": "Landed locally: improved new-project network error diagnostics in the frontend." + }, + "local_status": "landed", + "local_summary": "Landed locally: improved new-project network error diagnostics in the frontend.", + "triage_status": "landed", + "summary": "Landed locally: improved new-project network error diagnostics in the frontend.", + "coverage_status": "landed", + "coverage_summary": "Landed locally: improved new-project network error diagnostics in the frontend.", + "local_review": { + "status": "landed", + "summary": "Landed locally: improved new-project network error diagnostics in the frontend.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 124, + "title": "fix: robust JSON extraction for mixed LLM responses", + "url": "https://github.com/666ghj/MiroFish/pull/124", + "state": "open", + "created_at": "2026-03-10T21:50:31Z", + "updated_at": "2026-03-10T21:51:46Z", + "closed_at": null, + "merged_at": null, + "head": "fix/issue-64", + "head_ref_name": "fix/issue-64", + "head_sha": "08d5313024500371140c713f72bedcd730a99914", + "head_repo": "SergioChan/MiroFish", + "head_clone_url": "https://github.com/SergioChan/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "LLM API", + "size:M" + ], + "author": "SergioChan", + "body_excerpt": "## SummarynnHarden backend JSON parsing for LLM responses so mixed outputs (markdown fences, pre/post text) are handled more robustly, reducing 500 errors reported during ontology generation.nn## Changesnn- Updated `LLMClient.chat()` to remove `<think ...>...</think>` tags case-insensitivelyn- Added `LLMClient._extract_json_payload()` to normalize and extract JSON from noisy model responsesn- Upd…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-124", + "mirror_ref": "origin/mirror/upstream-pr-124", + "local_coverage": { + "number": 124, + "status": "landed", + "summary": "Landed locally: robust JSON extraction for mixed LLM responses." + }, + "local_status": "landed", + "local_summary": "Landed locally: robust JSON extraction for mixed LLM responses.", + "triage_status": "landed", + "summary": "Landed locally: robust JSON extraction for mixed LLM responses.", + "coverage_status": "landed", + "coverage_summary": "Landed locally: robust JSON extraction for mixed LLM responses.", + "local_review": { + "status": "landed", + "summary": "Landed locally: robust JSON extraction for mixed LLM responses.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 122, + "title": "fix(llm_client): remove response_format json_object for local LLM compatibility", + "url": "https://github.com/666ghj/MiroFish/pull/122", + "state": "open", + "created_at": "2026-03-10T18:20:49Z", + "updated_at": "2026-03-10T18:33:48Z", + "closed_at": null, + "merged_at": null, + "head": "fix/lm-studio-json-object-compat", + "head_ref_name": "fix/lm-studio-json-object-compat", + "head_sha": "481cc009a392549017c08f27b9500f5ab41415be", + "head_repo": "ImL1s/MiroFish", + "head_clone_url": "https://github.com/ImL1s/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "LLM API", + "size:XS" + ], + "author": "ImL1s", + "body_excerpt": "## Problem `chat_json()` uses `response_format={\"type\": \"json_object\"}`, but LM Studio and Ollama do not support this parameter (only `json_schema` or `text`), causing API calls to fail when using local LLMs. Related references: - LM Studio: https://github.com/lmstudio-ai/lmstudio-bug-tracker/issues/534 - Similar to issue #110 (API call failures) ## Solution Remove `response_format` from `chat_js…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-122", + "mirror_ref": "origin/mirror/upstream-pr-122", + "local_coverage": { + "number": 122, + "status": "landed", + "summary": "Landed locally: removed `response_format={type: json_object}` from `chat_json()` for LM Studio and Ollama compatibility." + }, + "local_status": "landed", + "local_summary": "Landed locally: removed `response_format={type: json_object}` from `chat_json()` for LM Studio and Ollama compatibility.", + "triage_status": "landed", + "summary": "Landed locally: removed `response_format={type: json_object}` from `chat_json()` for LM Studio and Ollama compatibility.", + "coverage_status": "landed", + "coverage_summary": "Landed locally: removed `response_format={type: json_object}` from `chat_json()` for LM Studio and Ollama compatibility.", + "local_review": { + "status": "landed", + "summary": "Landed locally: removed `response_format={type: json_object}` from `chat_json()` for LM Studio and Ollama compatibility.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 119, + "title": "feat: add an option to switch to english language", + "url": "https://github.com/666ghj/MiroFish/pull/119", + "state": "open", + "created_at": "2026-03-10T15:05:40Z", + "updated_at": "2026-03-10T18:14:33Z", + "closed_at": null, + "merged_at": null, + "head": "language-option", + "head_ref_name": "language-option", + "head_sha": "cb868d30438bced7e10fe0efd4d30db0e6c32e3e", + "head_repo": "Pratiyankkumar/MiroFish", + "head_clone_url": "https://github.com/Pratiyankkumar/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "enhancement", + "size:XXL" + ], + "author": "Pratiyankkumar", + "body_excerpt": "Right now the content of the website is mostly in Chinese , Added an button to switch between Chinese and english language . [`Demo Video`](https://drive.google.com/file/d/15VYI0J1SoDRf27Zvprm1P-D4MO8hA7yE/view?usp=sharing)", + "comment_count": 2, + "review_comment_count": 0, + "recent_comments": [ + { + "author": "Pratiyankkumar", + "created_at": "2026-03-10T18:14:16Z", + "updated_at": "2026-03-10T18:14:16Z", + "url": "https://github.com/666ghj/MiroFish/pull/119#issuecomment-4033470105", + "body_excerpt": "<img width=\"1470\" height=\"835\" alt=\"Screenshot 2026-03-10 at 11 42 33 PM\" src=\"https://github.com/user-attachments/assets/1483f8c1-da70-4d76-8058-6a5752204564\" /> **PR summary (last two prompts):** 1. **Error message i18n** – Added `errors…" + }, + { + "author": "Pratiyankkumar", + "created_at": "2026-03-10T18:14:33Z", + "updated_at": "2026-03-10T18:14:33Z", + "url": "https://github.com/666ghj/MiroFish/pull/119#issuecomment-4033471655", + "body_excerpt": "@hzr1937 please review" + } + ], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-119", + "mirror_ref": "origin/mirror/upstream-pr-119", + "local_coverage": { + "number": 119, + "status": "partial", + "summary": "Safe subset landed locally for persisted EN/ZH UI chrome, locale headers, browser-language-aware first-run locale bootstrap, deterministic Step 2/3 workflow logs, MainView workflow/build logs, locale-aware report and InsightForge sub-query prompt scaffolding, English-localized ReportAgent tool descriptions and parameter help, bilingual Step 4 Insight/Panorama parsing, deterministic Step 5 interview helper fallbacks/prompts, English-mode Oasis profile prompt empty-state fallbacks, English-localized GraphBuilderService worker progress strings, and English fallback labels in `zep_tools` Panorama/InsightForge report output, but broader backend/runtime localization remains a follow-up instead of a blind merge.", + "local_refs": [ + "frontend/src/views/MainView.vue", + "frontend/src/views/mainViewLogMessages.js", + "backend/app/services/graph_builder.py", + "frontend/src/components/Step4Report.vue", + "frontend/src/components/reportParsers.js", + "backend/app/services/report_agent.py", + "backend/app/services/zep_tools.py", + "backend/app/services/oasis_profile_generator.py", + "frontend/src/i18n/index.js", + "frontend/src/i18n/locales/en.js", + "frontend/src/i18n/locales/zh.js", + "backend/tests/test_graph_builder.py", + "backend/tests/test_report_agent.py", + "backend/tests/test_openai_compat_services.py", + "frontend/tests/i18n.test.mjs", + "frontend/tests/mainViewLogMessages.test.mjs", + "frontend/tests/reportParsers.test.mjs", + "backend/tests/test_zep_tools_i18n.py" + ], + "validation": [ + "uv run --project backend pytest -q backend/tests/test_graph_builder.py backend/tests/test_graph_upload_api.py", + "uv run --project backend pytest -q backend/tests/test_report_agent.py", + "uv run --project backend pytest -q backend/tests/test_openai_compat_services.py", + "uv run --project backend pytest -q backend/tests/test_zep_tools_i18n.py", + "scripts/test_backend_lite.sh", + "frontend: npm test", + "frontend: npm run build" + ] + }, + "local_status": "partial", + "local_summary": "Safe subset landed locally for persisted EN/ZH UI chrome, locale headers, browser-language-aware first-run locale bootstrap, deterministic Step 2/3 workflow logs, MainView workflow/build logs, locale-aware report and InsightForge sub-query prompt scaffolding, English-localized ReportAgent tool descriptions and parameter help, bilingual Step 4 Insight/Panorama parsing, deterministic Step 5 interview helper fallbacks/prompts, English-mode Oasis profile prompt empty-state fallbacks, English-localized GraphBuilderService worker progress strings, and English fallback labels in `zep_tools` Panorama/InsightForge report output, but broader backend/runtime localization remains a follow-up instead of a blind merge.", + "triage_status": "partial", + "summary": "Safe subset landed locally for persisted EN/ZH UI chrome, locale headers, browser-language-aware first-run locale bootstrap, deterministic Step 2/3 workflow logs, MainView workflow/build logs, locale-aware report and InsightForge sub-query prompt scaffolding, English-localized ReportAgent tool descriptions and parameter help, bilingual Step 4 Insight/Panorama parsing, deterministic Step 5 interview helper fallbacks/prompts, English-mode Oasis profile prompt empty-state fallbacks, English-localized GraphBuilderService worker progress strings, and English fallback labels in `zep_tools` Panorama/InsightForge report output, but broader backend/runtime localization remains a follow-up instead of a blind merge.", + "coverage_status": "partial", + "coverage_summary": "Safe subset landed locally for persisted EN/ZH UI chrome, locale headers, browser-language-aware first-run locale bootstrap, deterministic Step 2/3 workflow logs, MainView workflow/build logs, locale-aware report and InsightForge sub-query prompt scaffolding, English-localized ReportAgent tool descriptions and parameter help, bilingual Step 4 Insight/Panorama parsing, deterministic Step 5 interview helper fallbacks/prompts, English-mode Oasis profile prompt empty-state fallbacks, English-localized GraphBuilderService worker progress strings, and English fallback labels in `zep_tools` Panorama/InsightForge report output, but broader backend/runtime localization remains a follow-up instead of a blind merge.", + "local_review": { + "status": "partial", + "summary": "Safe subset landed locally for persisted EN/ZH UI chrome, locale headers, browser-language-aware first-run locale bootstrap, deterministic Step 2/3 workflow logs, MainView workflow/build logs, locale-aware report and InsightForge sub-query prompt scaffolding, English-localized ReportAgent tool descriptions and parameter help, bilingual Step 4 Insight/Panorama parsing, deterministic Step 5 interview helper fallbacks/prompts, English-mode Oasis profile prompt empty-state fallbacks, English-localized GraphBuilderService worker progress strings, and English fallback labels in `zep_tools` Panorama/InsightForge report output, but broader backend/runtime localization remains a follow-up instead of a blind merge.", + "local_refs": [ + "frontend/src/views/MainView.vue", + "frontend/src/views/mainViewLogMessages.js", + "backend/app/services/graph_builder.py", + "frontend/src/components/Step4Report.vue", + "frontend/src/components/reportParsers.js", + "backend/app/services/report_agent.py", + "backend/app/services/zep_tools.py", + "backend/app/services/oasis_profile_generator.py", + "frontend/src/i18n/index.js", + "frontend/src/i18n/locales/en.js", + "frontend/src/i18n/locales/zh.js", + "backend/tests/test_graph_builder.py", + "backend/tests/test_report_agent.py", + "backend/tests/test_openai_compat_services.py", + "frontend/tests/i18n.test.mjs", + "frontend/tests/mainViewLogMessages.test.mjs", + "frontend/tests/reportParsers.test.mjs", + "backend/tests/test_zep_tools_i18n.py" + ], + "validation": [ + "uv run --project backend pytest -q backend/tests/test_graph_builder.py backend/tests/test_graph_upload_api.py", + "uv run --project backend pytest -q backend/tests/test_report_agent.py", + "uv run --project backend pytest -q backend/tests/test_openai_compat_services.py", + "uv run --project backend pytest -q backend/tests/test_zep_tools_i18n.py", + "scripts/test_backend_lite.sh", + "frontend: npm test", + "frontend: npm run build" + ], + "notes": null + } + }, + { + "number": 89, + "title": "feat(graph): update graph rendering with Sigma.js and integrate new d…", + "url": "https://github.com/666ghj/MiroFish/pull/89", + "state": "closed", + "created_at": "2026-03-08T12:09:13Z", + "updated_at": "2026-03-10T09:49:08Z", + "closed_at": "2026-03-10T09:49:08Z", + "merged_at": null, + "head": "main", + "head_ref_name": "main", + "head_sha": "4834cc1fae5d03faad2699067a22e1beaef7dda8", + "head_repo": "zzfe-501/MiroFish", + "head_clone_url": "https://github.com/zzfe-501/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "enhancement", + "size:XL" + ], + "author": "zzfe-501", + "body_excerpt": "…ependencies", + "comment_count": 1, + "review_comment_count": 0, + "recent_comments": [ + { + "author": "JasonOA888", + "created_at": "2026-03-09T17:01:33Z", + "updated_at": "2026-03-09T17:01:33Z", + "url": "https://github.com/666ghj/MiroFish/pull/89#issuecomment-4025301133", + "body_excerpt": "## 代码审查反馈 这是一个非常有价值的大型重构!从D3.js SVG迁移到Sigma.js WebGL是正确的技术决策,特别是对于大规模知识图谱渲染。 ### 优点 1. **性能提升** - WebGL渲染相比SVG在节点数>500时会有显著性能优势 2. **代码结构改进** - `prepareGraphData`函数抽取使逻辑更清晰 3. **交互优化** - `suppressNextClick`和`DRAG_THRESHOLD_PX`解决了拖拽/点击冲突的经典…" + } + ], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-89", + "mirror_ref": "origin/mirror/upstream-pr-89", + "triage_status": "untracked", + "summary": "…ependencies", + "coverage_status": "untracked", + "coverage_summary": "…ependencies", + "local_review": { + "status": "unreviewed", + "summary": "…ependencies", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 118, + "title": "feat(ragflow): add RAGflow as alternative graph backend with full pip…", + "url": "https://github.com/666ghj/MiroFish/pull/118", + "state": "open", + "created_at": "2026-03-10T09:29:33Z", + "updated_at": "2026-03-10T09:30:44Z", + "closed_at": null, + "merged_at": null, + "head": "fix/ragflow-pattern-compliance", + "head_ref_name": "fix/ragflow-pattern-compliance", + "head_sha": "d700c0c7d0a595f20fd415c476a4b3547e1bfb0d", + "head_repo": "pratyush618/MiroFish", + "head_clone_url": "https://github.com/pratyush618/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "documentation", + "enhancement", + "size:XXL" + ], + "author": "pratyush618", + "body_excerpt": "…eline compliance - Add RagflowGraphBuilderService and RagflowEntityReader for self-hosted graph support - Add _get_entity_reader() helper in simulation.py to auto-select reader by graph_id prefix - Fix 4 simulation endpoints (get_graph_entities, get_entity_detail, get_entities_by_type, generate_profiles) to support ragflow_ graph IDs - Guard ZepGraphMemoryManager.create_updater() to skip for RAG…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-118", + "mirror_ref": "origin/mirror/upstream-pr-118", + "local_coverage": { + "number": 118, + "status": "not_safe", + "summary": "Not safe to cherry-pick: RAGflow support threads a second graph backend through core graph/simulation paths without the targeted regression coverage or rebasing needed on top of current local changes." + }, + "local_status": "not_safe", + "local_summary": "Not safe to cherry-pick: RAGflow support threads a second graph backend through core graph/simulation paths without the targeted regression coverage or rebasing needed on top of current local changes.", + "triage_status": "not_safe", + "summary": "Not safe to cherry-pick: RAGflow support threads a second graph backend through core graph/simulation paths without the targeted regression coverage or rebasing needed on top of current local changes.", + "coverage_status": "not_safe", + "coverage_summary": "Not safe to cherry-pick: RAGflow support threads a second graph backend through core graph/simulation paths without the targeted regression coverage or rebasing needed on top of current local changes.", + "local_review": { + "status": "not_safe", + "summary": "Not safe to cherry-pick: RAGflow support threads a second graph backend through core graph/simulation paths without the targeted regression coverage or rebasing needed on top of current local changes.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 116, + "title": "Upgrade GitHub Actions", + "url": "https://github.com/666ghj/MiroFish/pull/116", + "state": "open", + "created_at": "2026-03-10T07:30:38Z", + "updated_at": "2026-03-10T07:30:42Z", + "closed_at": null, + "merged_at": null, + "head": "chore/upgrade-actions", + "head_ref_name": "chore/upgrade-actions", + "head_sha": "ddd20d9b615b660b6fc4604add767aaa30cb2f71", + "head_repo": "ailuntz/MiroFish", + "head_clone_url": "https://github.com/ailuntz/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "size:XS" + ], + "author": "ailuntz", + "body_excerpt": "Fixes #92.\\n\\nBump checkout and build-push to latest major versions.", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-116", + "mirror_ref": "origin/mirror/upstream-pr-116", + "local_coverage": { + "number": 116, + "status": "landed", + "summary": "Landed locally: GitHub Actions dependency upgrades." + }, + "local_status": "landed", + "local_summary": "Landed locally: GitHub Actions dependency upgrades.", + "triage_status": "landed", + "summary": "Landed locally: GitHub Actions dependency upgrades.", + "coverage_status": "landed", + "coverage_summary": "Landed locally: GitHub Actions dependency upgrades.", + "local_review": { + "status": "landed", + "summary": "Landed locally: GitHub Actions dependency upgrades.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 115, + "title": "Use SPDX license string", + "url": "https://github.com/666ghj/MiroFish/pull/115", + "state": "open", + "created_at": "2026-03-10T07:28:37Z", + "updated_at": "2026-03-10T07:28:41Z", + "closed_at": null, + "merged_at": null, + "head": "fix/pyproject-license", + "head_ref_name": "fix/pyproject-license", + "head_sha": "e7a1fbb4c2166c3ed95f0ac40b14c443c35c260b", + "head_repo": "ailuntz/MiroFish", + "head_clone_url": "https://github.com/ailuntz/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "size:XS" + ], + "author": "ailuntz", + "body_excerpt": "Fixes #46.\\n\\nSwitch project.license to SPDX string to avoid the deprecation warning.", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-115", + "mirror_ref": "origin/mirror/upstream-pr-115", + "local_coverage": { + "number": 115, + "status": "landed", + "summary": "Landed locally: SPDX license string metadata cleanup." + }, + "local_status": "landed", + "local_summary": "Landed locally: SPDX license string metadata cleanup.", + "triage_status": "landed", + "summary": "Landed locally: SPDX license string metadata cleanup.", + "coverage_status": "landed", + "coverage_summary": "Landed locally: SPDX license string metadata cleanup.", + "local_review": { + "status": "landed", + "summary": "Landed locally: SPDX license string metadata cleanup.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 114, + "title": "Fix API base URL fallback", + "url": "https://github.com/666ghj/MiroFish/pull/114", + "state": "open", + "created_at": "2026-03-10T07:21:29Z", + "updated_at": "2026-03-10T07:21:49Z", + "closed_at": null, + "merged_at": null, + "head": "fix/api-baseurl-default", + "head_ref_name": "fix/api-baseurl-default", + "head_sha": "866c7849c3bf9a35dea560e5f22030b3fab03c53", + "head_repo": "ailuntz/MiroFish", + "head_clone_url": "https://github.com/ailuntz/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "size:S" + ], + "author": "ailuntz", + "body_excerpt": "Fixes #93.\\n\\nUse VITE_API_BASE_URL when set; otherwise default to current origin.", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-114", + "mirror_ref": "origin/mirror/upstream-pr-114", + "local_coverage": { + "number": 114, + "status": "superseded", + "summary": "Superseded locally by the current frontend API client, which already falls back to the runtime origin and supports custom base URLs." + }, + "local_status": "superseded", + "local_summary": "Superseded locally by the current frontend API client, which already falls back to the runtime origin and supports custom base URLs.", + "triage_status": "superseded", + "summary": "Superseded locally by the current frontend API client, which already falls back to the runtime origin and supports custom base URLs.", + "coverage_status": "superseded", + "coverage_summary": "Superseded locally by the current frontend API client, which already falls back to the runtime origin and supports custom base URLs.", + "local_review": { + "status": "superseded", + "summary": "Superseded locally by the current frontend API client, which already falls back to the runtime origin and supports custom base URLs.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 113, + "title": "docs(readme): add Japanese README", + "url": "https://github.com/666ghj/MiroFish/pull/113", + "state": "open", + "created_at": "2026-03-10T06:43:16Z", + "updated_at": "2026-03-10T06:44:34Z", + "closed_at": null, + "merged_at": null, + "head": "add-ja-doc", + "head_ref_name": "add-ja-doc", + "head_sha": "0531fa640d186bf27de03c01d4bcc4bcd315cf4d", + "head_repo": "eltociear/MiroFish", + "head_clone_url": "https://github.com/eltociear/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "documentation", + "size:L" + ], + "author": "eltociear", + "body_excerpt": "I created Japanese translated README.", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-113", + "mirror_ref": "origin/mirror/upstream-pr-113", + "local_coverage": { + "number": 113, + "status": "landed", + "summary": "Landed locally: Japanese README added and cross-links normalized." + }, + "local_status": "landed", + "local_summary": "Landed locally: Japanese README added and cross-links normalized.", + "triage_status": "landed", + "summary": "Landed locally: Japanese README added and cross-links normalized.", + "coverage_status": "landed", + "coverage_summary": "Landed locally: Japanese README added and cross-links normalized.", + "local_review": { + "status": "landed", + "summary": "Landed locally: Japanese README added and cross-links normalized.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 112, + "title": "Korean README.md added", + "url": "https://github.com/666ghj/MiroFish/pull/112", + "state": "open", + "created_at": "2026-03-10T05:25:01Z", + "updated_at": "2026-03-10T05:25:55Z", + "closed_at": null, + "merged_at": null, + "head": "main", + "head_ref_name": "main", + "head_sha": "9817c535d77dbd69ccefa58ca96cbae497dbff47", + "head_repo": "waitle/MiroFish-kor", + "head_clone_url": "https://github.com/waitle/MiroFish-kor.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "documentation", + "size:L" + ], + "author": "waitle", + "body_excerpt": "AI translated Korean readme file", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-112", + "mirror_ref": "origin/mirror/upstream-pr-112", + "local_coverage": { + "number": 112, + "status": "landed", + "summary": "Landed locally: Korean README added and cross-links normalized." + }, + "local_status": "landed", + "local_summary": "Landed locally: Korean README added and cross-links normalized.", + "triage_status": "landed", + "summary": "Landed locally: Korean README added and cross-links normalized.", + "coverage_status": "landed", + "coverage_summary": "Landed locally: Korean README added and cross-links normalized.", + "local_review": { + "status": "landed", + "summary": "Landed locally: Korean README added and cross-links normalized.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 72, + "title": "清理模型返回的markdown标记", + "url": "https://github.com/666ghj/MiroFish/pull/72", + "state": "open", + "created_at": "2026-02-15T05:27:05Z", + "updated_at": "2026-03-10T02:58:45Z", + "closed_at": null, + "merged_at": null, + "head": "main", + "head_ref_name": "main", + "head_sha": "42f9f9a72caf08f77d1db969ae7d7116d1a28d5f", + "head_repo": "MoeclubM/MiroFish", + "head_clone_url": "https://github.com/MoeclubM/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "dirty", + "labels": [ + "size:S" + ], + "author": "MoeclubM", + "body_excerpt": "生成本体结构时部分模型(已测试minimax-m2.1 minimax-m2.5 glm-4.7 glm-5都有相同情况)似乎不遵守json_object的格式,会返回markdown包裹的json代码块 导致json.loads()解析错误 对md代码块标记进行了清理并额外增加了try except防止出错导致500 https://github.com/666ghj/MiroFish/issues/64 https://github.com/666ghj/MiroFish/issues/58 https://github.com/666ghj/MiroFish/issues/48 不确定是否都是这个原因导致的", + "comment_count": 3, + "review_comment_count": 3, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-03-05T16:07:48Z", + "updated_at": "2026-03-05T16:07:48Z", + "url": "https://github.com/666ghj/MiroFish/pull/72#issuecomment-4006153143", + "body_excerpt": "经过测试好像这样依旧会500,你调试的时候是可以解决问题的吗?" + }, + { + "author": "666ghj", + "created_at": "2026-03-05T16:19:41Z", + "updated_at": "2026-03-05T16:19:41Z", + "url": "https://github.com/666ghj/MiroFish/pull/72#issuecomment-4006236547", + "body_excerpt": "我找到原因了, MiniMax M2.5 是推理模型,即使通过 OpenAI 兼容 API 调用,其 content 字段会包含 <think>...</think> 思维链内容。实际返回大致如下: ``` <think> 用户需要我生成一个本体结构...让我分析这些文档... </think> ```json {\"entity_types\": [...], \"edge_types\": [...]} ``` 而 `chat_json()` 直接对整个 `content` 做…" + }, + { + "author": "Gresdy", + "created_at": "2026-03-10T02:58:06Z", + "updated_at": "2026-03-10T02:58:45Z", + "url": "https://github.com/666ghj/MiroFish/pull/72#issuecomment-4028265481", + "body_excerpt": "最新的代码,今天使用minimax2.5还是存在500错误 [backend] [02:52:07] INFO: 调用 LLM 生成本体定义... [backend] 192.168.65.1 - - [10/Mar/2026 02:52:09] \"POST /api/graph/ontology/generate HTTP/1.1\" 500 -" + } + ], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-72", + "mirror_ref": "origin/mirror/upstream-pr-72", + "local_coverage": { + "number": 72, + "status": "superseded", + "summary": "Superseded locally by the broader `_extract_json_payload()` handling in `backend/app/utils/llm_client.py`." + }, + "local_status": "superseded", + "local_summary": "Superseded locally by the broader `_extract_json_payload()` handling in `backend/app/utils/llm_client.py`.", + "triage_status": "superseded", + "summary": "Superseded locally by the broader `_extract_json_payload()` handling in `backend/app/utils/llm_client.py`.", + "coverage_status": "superseded", + "coverage_summary": "Superseded locally by the broader `_extract_json_payload()` handling in `backend/app/utils/llm_client.py`.", + "local_review": { + "status": "superseded", + "summary": "Superseded locally by the broader `_extract_json_payload()` handling in `backend/app/utils/llm_client.py`.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 108, + "title": "feat(installer): add Windows installer build scripts", + "url": "https://github.com/666ghj/MiroFish/pull/108", + "state": "open", + "created_at": "2026-03-09T17:14:55Z", + "updated_at": "2026-03-09T17:16:21Z", + "closed_at": null, + "merged_at": null, + "head": "feat/windows-installer", + "head_ref_name": "feat/windows-installer", + "head_sha": "27ffc561637a2fd67a87415691dd5fddeb4a2e2d", + "head_repo": "JasonOA888/MiroFish", + "head_clone_url": "https://github.com/JasonOA888/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "enhancement", + "size:XL" + ], + "author": "JasonOA888", + "body_excerpt": "Implements #70 - Windows installation program packaging ## Features - PowerShell build script with embedded Python and PyInstaller modes - Inno Setup integration for professional installer - Portable version generation - API key configuration during installation - Desktop and Start Menu shortcuts ## Build Options ``` ./installer/build.ps1 # Default (embedded Python) ./installer/build.ps1 -PyInsta…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-108", + "mirror_ref": "origin/mirror/upstream-pr-108", + "local_coverage": { + "number": 108, + "status": "not_safe", + "summary": "Not safe to cherry-pick: the Windows packaging flow targets the wrong runtime entrypoints, serves the frontend in dev mode, and rebundles already-landed workflow changes." + }, + "local_status": "not_safe", + "local_summary": "Not safe to cherry-pick: the Windows packaging flow targets the wrong runtime entrypoints, serves the frontend in dev mode, and rebundles already-landed workflow changes.", + "triage_status": "not_safe", + "summary": "Not safe to cherry-pick: the Windows packaging flow targets the wrong runtime entrypoints, serves the frontend in dev mode, and rebundles already-landed workflow changes.", + "coverage_status": "not_safe", + "coverage_summary": "Not safe to cherry-pick: the Windows packaging flow targets the wrong runtime entrypoints, serves the frontend in dev mode, and rebundles already-landed workflow changes.", + "local_review": { + "status": "not_safe", + "summary": "Not safe to cherry-pick: the Windows packaging flow targets the wrong runtime entrypoints, serves the frontend in dev mode, and rebundles already-landed workflow changes.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 104, + "title": "fix: make vite proxy target configurable via environment variable", + "url": "https://github.com/666ghj/MiroFish/pull/104", + "state": "open", + "created_at": "2026-03-09T09:05:37Z", + "updated_at": "2026-03-09T09:06:54Z", + "closed_at": null, + "merged_at": null, + "head": "fix/remove-hardcoded-api-url", + "head_ref_name": "fix/remove-hardcoded-api-url", + "head_sha": "6f2d41262fc4869b2037bcdda7d60cf20de4f6f1", + "head_repo": "nil957/MiroFish", + "head_clone_url": "https://github.com/nil957/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "documentation", + "size:M" + ], + "author": "nil957", + "body_excerpt": "## Summary This PR addresses issue #93 - `frontend/src/api/index.js`中的`baseURL`不应该硬编码 ## Problem While `api/index.js` already supports `VITE_API_BASE_URL`, the vite dev server proxy in `vite.config.js` was still hardcoded to `http://localhost:5001`, causing issues when: - Deploying with Docker using custom port mappings - Running backend on a remote server - Using non-standard ports ## Changes 1.…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-104", + "mirror_ref": "origin/mirror/upstream-pr-104", + "local_coverage": { + "number": 104, + "status": "landed", + "summary": "Landed locally: Vite dev proxy target is configurable via `VITE_API_BASE_URL`." + }, + "local_status": "landed", + "local_summary": "Landed locally: Vite dev proxy target is configurable via `VITE_API_BASE_URL`.", + "triage_status": "landed", + "summary": "Landed locally: Vite dev proxy target is configurable via `VITE_API_BASE_URL`.", + "coverage_status": "landed", + "coverage_summary": "Landed locally: Vite dev proxy target is configurable via `VITE_API_BASE_URL`.", + "local_review": { + "status": "landed", + "summary": "Landed locally: Vite dev proxy target is configurable via `VITE_API_BASE_URL`.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 103, + "title": "ci: upgrade GitHub Actions and add ARM64 Docker support", + "url": "https://github.com/666ghj/MiroFish/pull/103", + "state": "open", + "created_at": "2026-03-09T09:03:45Z", + "updated_at": "2026-03-09T09:04:59Z", + "closed_at": null, + "merged_at": null, + "head": "fix/upgrade-actions-and-arm-support", + "head_ref_name": "fix/upgrade-actions-and-arm-support", + "head_sha": "40e92a66f2b91e9fc211e226f4beab47136170cb", + "head_repo": "nil957/MiroFish", + "head_clone_url": "https://github.com/nil957/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "enhancement", + "size:XS" + ], + "author": "nil957", + "body_excerpt": "## Summary This PR addresses two issues: - #92 - Upgrade GitHub Actions - #99 - Docker镜像没有arm版本 ## Changes 1. **Upgraded docker/build-push-action** from v5 to v6 2. **Added multi-platform build support** for both `linux/amd64` and `linux/arm64` 3. **Added GitHub Actions cache** (`cache-from` and `cache-to`) for faster subsequent builds ## Benefits - Users on ARM-based machines can now use the off…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-103", + "mirror_ref": "origin/mirror/upstream-pr-103", + "local_coverage": { + "number": 103, + "status": "landed", + "summary": "Landed locally: Docker workflow now builds ARM64 images and carries the related cache/buildx improvements." + }, + "local_status": "landed", + "local_summary": "Landed locally: Docker workflow now builds ARM64 images and carries the related cache/buildx improvements.", + "triage_status": "landed", + "summary": "Landed locally: Docker workflow now builds ARM64 images and carries the related cache/buildx improvements.", + "coverage_status": "landed", + "coverage_summary": "Landed locally: Docker workflow now builds ARM64 images and carries the related cache/buildx improvements.", + "local_review": { + "status": "landed", + "summary": "Landed locally: Docker workflow now builds ARM64 images and carries the related cache/buildx improvements.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 91, + "title": "求个能稳定加速gpt和gemini网页和api的vpn代理,我现在用的api好不稳 ", + "url": "https://github.com/666ghj/MiroFish/pull/91", + "state": "closed", + "created_at": "2026-03-08T15:25:24Z", + "updated_at": "2026-03-09T08:55:26Z", + "closed_at": "2026-03-09T08:55:26Z", + "merged_at": null, + "head": "main", + "head_ref_name": "main", + "head_sha": "02d6fcfc6efb6806db774a6f851695c7f9e03262", + "head_repo": "leon-x-labs/MiroFish", + "head_clone_url": "https://github.com/leon-x-labs/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "size:XS" + ], + "author": "leon-x-labs", + "body_excerpt": "test", + "comment_count": 1, + "review_comment_count": 0, + "recent_comments": [ + { + "author": "alex-net-dev", + "created_at": "2026-03-09T04:46:19Z", + "updated_at": "2026-03-09T04:46:19Z", + "url": "https://github.com/666ghj/MiroFish/pull/91#issuecomment-4021071485", + "body_excerpt": "我用这个挺久了,速度快稳定不贵,官网 [https://cloud.yuncataff.top](https://cloud.yuncataff.top/aff/github-com/666ghj/MiroFish/pull/91/#/register?code=ryEEDL2F)" + } + ], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-91", + "mirror_ref": "origin/mirror/upstream-pr-91", + "triage_status": "untracked", + "summary": "test", + "coverage_status": "untracked", + "coverage_summary": "test", + "local_review": { + "status": "unreviewed", + "summary": "test", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 102, + "title": "fix(ci): add multi-platform Docker build for ARM64 support", + "url": "https://github.com/666ghj/MiroFish/pull/102", + "state": "open", + "created_at": "2026-03-09T08:25:27Z", + "updated_at": "2026-03-09T08:26:30Z", + "closed_at": null, + "merged_at": null, + "head": "fix/docker-multiplatform", + "head_ref_name": "fix/docker-multiplatform", + "head_sha": "14afd56e2c53b6f06692f2e00ce53f7ecb2aca9f", + "head_repo": "JasonOA888/MiroFish", + "head_clone_url": "https://github.com/JasonOA888/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "enhancement", + "size:XS" + ], + "author": "JasonOA888", + "body_excerpt": "## Problem ARM64 machines (e.g., Apple Silicon Macs, ARM servers) cannot deploy MiroFish via Docker: ``` no matching manifest for linux/arm64/v8 in the manifest list entries ``` ## Solution Add `platforms: linux/amd64,linux/arm64` to `docker/build-push-action`. The workflow already has QEMU and Buildx configured, just needed the platforms flag. ## Changes - Update `.github/workflows/docker-image.…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-102", + "mirror_ref": "origin/mirror/upstream-pr-102", + "local_coverage": { + "number": 102, + "status": "superseded", + "summary": "Superseded locally by the landed multi-platform Docker workflow that already builds `linux/amd64` and `linux/arm64` images." + }, + "local_status": "superseded", + "local_summary": "Superseded locally by the landed multi-platform Docker workflow that already builds `linux/amd64` and `linux/arm64` images.", + "triage_status": "superseded", + "summary": "Superseded locally by the landed multi-platform Docker workflow that already builds `linux/amd64` and `linux/arm64` images.", + "coverage_status": "superseded", + "coverage_summary": "Superseded locally by the landed multi-platform Docker workflow that already builds `linux/amd64` and `linux/arm64` images.", + "local_review": { + "status": "superseded", + "summary": "Superseded locally by the landed multi-platform Docker workflow that already builds `linux/amd64` and `linux/arm64` images.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 101, + "title": "feat(utils): add json_utils module for robust LLM JSON parsing", + "url": "https://github.com/666ghj/MiroFish/pull/101", + "state": "open", + "created_at": "2026-03-09T08:23:07Z", + "updated_at": "2026-03-09T08:24:20Z", + "closed_at": null, + "merged_at": null, + "head": "feat/json-utils-helper", + "head_ref_name": "feat/json-utils-helper", + "head_sha": "b46ee9befd1f792b986b758920810098ce582489", + "head_repo": "JasonOA888/MiroFish", + "head_clone_url": "https://github.com/JasonOA888/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "enhancement", + "size:M" + ], + "author": "JasonOA888", + "body_excerpt": "## Problem Some LLM models don't respect `json_object` format and return markdown-wrapped JSON: ``` ```json {\"key\": \"value\"} ``` ``` This causes `json.loads()` to fail with JSONDecodeError. Affected models: MiniMax M2.5, GLM-4.7, GLM-5 Related issues: #72, #64, #58, #48 ## Solution Add `json_utils.py` module with: - `clean_llm_json_response()` - Strip markdown code blocks - `parse_llm_json()` - P…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-101", + "mirror_ref": "origin/mirror/upstream-pr-101", + "local_coverage": { + "number": 101, + "status": "superseded", + "summary": "Superseded locally: the useful JSON-cleanup intent is already covered by the current LLM payload extraction hardening, and the branch predates substantial newer backend/frontend work." + }, + "local_status": "superseded", + "local_summary": "Superseded locally: the useful JSON-cleanup intent is already covered by the current LLM payload extraction hardening, and the branch predates substantial newer backend/frontend work.", + "triage_status": "superseded", + "summary": "Superseded locally: the useful JSON-cleanup intent is already covered by the current LLM payload extraction hardening, and the branch predates substantial newer backend/frontend work.", + "coverage_status": "superseded", + "coverage_summary": "Superseded locally: the useful JSON-cleanup intent is already covered by the current LLM payload extraction hardening, and the branch predates substantial newer backend/frontend work.", + "local_review": { + "status": "superseded", + "summary": "Superseded locally: the useful JSON-cleanup intent is already covered by the current LLM payload extraction hardening, and the branch predates substantial newer backend/frontend work.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 100, + "title": "fix(frontend): use relative baseURL in production, avoid hardcoded localhost", + "url": "https://github.com/666ghj/MiroFish/pull/100", + "state": "open", + "created_at": "2026-03-09T08:19:23Z", + "updated_at": "2026-03-09T08:20:38Z", + "closed_at": null, + "merged_at": null, + "head": "fix/frontend-baseurl", + "head_ref_name": "fix/frontend-baseurl", + "head_sha": "1e3451b05814d612c0864013c73f6bcd5b410f04", + "head_repo": "JasonOA888/MiroFish", + "head_clone_url": "https://github.com/JasonOA888/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "enhancement", + "size:S" + ], + "author": "JasonOA888", + "body_excerpt": "## Problem Frontend `baseURL` defaults to `http://localhost:5001` in all environments, causing: 1. Users can only access from the machine running MiroFish 2. Docker deployments with non-5001 ports fail 3. Server deployments require frontend rebuild to change URL ## Solution Make baseURL environment-aware: - **Production**: Use relative path (empty string) for same-origin deployment - **Developmen…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-100", + "mirror_ref": "origin/mirror/upstream-pr-100", + "local_coverage": { + "number": 100, + "status": "superseded", + "summary": "Superseded locally by the shared frontend API base-url resolver, which already uses runtime-origin fallback and repo-specific localhost handling." + }, + "local_status": "superseded", + "local_summary": "Superseded locally by the shared frontend API base-url resolver, which already uses runtime-origin fallback and repo-specific localhost handling.", + "triage_status": "superseded", + "summary": "Superseded locally by the shared frontend API base-url resolver, which already uses runtime-origin fallback and repo-specific localhost handling.", + "coverage_status": "superseded", + "coverage_summary": "Superseded locally by the shared frontend API base-url resolver, which already uses runtime-origin fallback and repo-specific localhost handling.", + "local_review": { + "status": "superseded", + "summary": "Superseded locally by the shared frontend API base-url resolver, which already uses runtime-origin fallback and repo-specific localhost handling.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 97, + "title": "Feature/local graphrag", + "url": "https://github.com/666ghj/MiroFish/pull/97", + "state": "closed", + "created_at": "2026-03-09T03:06:31Z", + "updated_at": "2026-03-09T03:10:49Z", + "closed_at": "2026-03-09T03:08:50Z", + "merged_at": null, + "head": "feature/local-graphrag", + "head_ref_name": "feature/local-graphrag", + "head_sha": "f5eccdc50555646161cc6cf69de536612b86b397", + "head_repo": "Jerry050512/MiroFish", + "head_clone_url": "https://github.com/Jerry050512/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "Memory Layer", + "size:XXL" + ], + "author": "Jerry050512", + "body_excerpt": "implement local GraphRAG to replace Zep dependency", + "comment_count": 1, + "review_comment_count": 0, + "recent_comments": [ + { + "author": "Jerry050512", + "created_at": "2026-03-09T03:10:49Z", + "updated_at": "2026-03-09T03:10:49Z", + "url": "https://github.com/666ghj/MiroFish/pull/97#issuecomment-4020800027", + "body_excerpt": "Sorry, I intended to PR to my own forked repository but accidentally submitted it here. I'll close this PR now. Apologies for the noise!" + } + ], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-97", + "mirror_ref": "origin/mirror/upstream-pr-97", + "triage_status": "untracked", + "summary": "implement local GraphRAG to replace Zep dependency", + "coverage_status": "untracked", + "coverage_summary": "implement local GraphRAG to replace Zep dependency", + "local_review": { + "status": "unreviewed", + "summary": "implement local GraphRAG to replace Zep dependency", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 87, + "title": "Upgrade GitHub Actions to latest versions", + "url": "https://github.com/666ghj/MiroFish/pull/87", + "state": "open", + "created_at": "2026-03-08T09:09:13Z", + "updated_at": "2026-03-08T09:09:19Z", + "closed_at": null, + "merged_at": null, + "head": "upgrade-github-actions-node24-general", + "head_ref_name": "upgrade-github-actions-node24-general", + "head_sha": "793d1581107a1fd4ffe120ff71ba82f674a8f206", + "head_repo": "salmanmkc/MiroFish", + "head_clone_url": "https://github.com/salmanmkc/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "size:S" + ], + "author": "salmanmkc", + "body_excerpt": "## Summary Upgrade GitHub Actions to their latest versions for improved features, bug fixes, and security updates. ## Changes | Action | Old Version(s) | New Version | Release | Files | |--------|---------------|-------------|---------|-------| | `docker/build-push-action` | [`v5`](https://github.com/docker/build-push-action/releases/tag/v5) | [`v7`](https://github.com/docker/build-push-action/re…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-87", + "mirror_ref": "origin/mirror/upstream-pr-87", + "local_coverage": { + "number": 87, + "status": "superseded", + "summary": "Superseded locally by the broader GitHub Actions upgrade sweep from upstream PR #116." + }, + "local_status": "superseded", + "local_summary": "Superseded locally by the broader GitHub Actions upgrade sweep from upstream PR #116.", + "triage_status": "superseded", + "summary": "Superseded locally by the broader GitHub Actions upgrade sweep from upstream PR #116.", + "coverage_status": "superseded", + "coverage_summary": "Superseded locally by the broader GitHub Actions upgrade sweep from upstream PR #116.", + "local_review": { + "status": "superseded", + "summary": "Superseded locally by the broader GitHub Actions upgrade sweep from upstream PR #116.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 86, + "title": "Upgrade GitHub Actions for Node 24 compatibility", + "url": "https://github.com/666ghj/MiroFish/pull/86", + "state": "open", + "created_at": "2026-03-08T09:09:10Z", + "updated_at": "2026-03-08T09:09:14Z", + "closed_at": null, + "merged_at": null, + "head": "upgrade-github-actions-node24", + "head_ref_name": "upgrade-github-actions-node24", + "head_sha": "265a89bf577ca0e9e9c91090bbf161abc350f25b", + "head_repo": "salmanmkc/MiroFish", + "head_clone_url": "https://github.com/salmanmkc/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "size:XS" + ], + "author": "salmanmkc", + "body_excerpt": "## Summary Upgrade GitHub Actions to their latest versions to ensure compatibility with Node 24, as Node 20 will reach end-of-life in April 2026. ## Changes | Action | Old Version(s) | New Version | Release | Files | |--------|---------------|-------------|---------|-------| | `actions/checkout` | [`v4`](https://github.com/actions/checkout/releases/tag/v4) | [`v6`](https://github.com/actions/chec…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-86", + "mirror_ref": "origin/mirror/upstream-pr-86", + "local_coverage": { + "number": 86, + "status": "superseded", + "summary": "Superseded locally by the newer workflow updates already carried on this branch, including the later ARM64/cache improvements." + }, + "local_status": "superseded", + "local_summary": "Superseded locally by the newer workflow updates already carried on this branch, including the later ARM64/cache improvements.", + "triage_status": "superseded", + "summary": "Superseded locally by the newer workflow updates already carried on this branch, including the later ARM64/cache improvements.", + "coverage_status": "superseded", + "coverage_summary": "Superseded locally by the newer workflow updates already carried on this branch, including the later ARM64/cache improvements.", + "local_review": { + "status": "superseded", + "summary": "Superseded locally by the newer workflow updates already carried on this branch, including the later ARM64/cache improvements.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 82, + "title": "[Security] Fix CRITICAL vulnerability: CVE-2025-64712", + "url": "https://github.com/666ghj/MiroFish/pull/82", + "state": "open", + "created_at": "2026-03-08T02:45:42Z", + "updated_at": "2026-03-08T02:45:46Z", + "closed_at": null, + "merged_at": null, + "head": "fix-cve-2025-64712-unstructured", + "head_ref_name": "fix-cve-2025-64712-unstructured", + "head_sha": "c0b1fee634ab03334f6eb1914a0498bc9b172056", + "head_repo": "orbisai0security/MiroFish", + "head_clone_url": "https://github.com/orbisai0security/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "size:XS" + ], + "author": "orbisai0security", + "body_excerpt": "## Security Fix This PR addresses a **CRITICAL** severity vulnerability detected by our security scanner. ### Security Impact Assessment | Aspect | Rating | Rationale | |--------|--------|-----------| | Impact | Critical | In MiroFish's backend, which likely processes unstructured data including MSG files via the Unstructured library, exploitation could allow an attacker to write arbitrary files…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-82", + "mirror_ref": "origin/mirror/upstream-pr-82", + "local_coverage": { + "number": 82, + "status": "covered", + "summary": "The underlying dependency-risk issue is addressed locally without cherry-picking: simulation/runtime packages moved behind an explicit optional install path instead of re-adding `unstructured` to the default backend dependencies, and the remaining optional simulation lock now resolves `pillow==10.4.0`." + }, + "local_status": "covered", + "local_summary": "The underlying dependency-risk issue is addressed locally without cherry-picking: simulation/runtime packages moved behind an explicit optional install path instead of re-adding `unstructured` to the default backend dependencies, and the remaining optional simulation lock now resolves `pillow==10.4.0`.", + "triage_status": "covered", + "summary": "The underlying dependency-risk issue is addressed locally without cherry-picking: simulation/runtime packages moved behind an explicit optional install path instead of re-adding `unstructured` to the default backend dependencies, and the remaining optional simulation lock now resolves `pillow==10.4.0`.", + "coverage_status": "covered", + "coverage_summary": "The underlying dependency-risk issue is addressed locally without cherry-picking: simulation/runtime packages moved behind an explicit optional install path instead of re-adding `unstructured` to the default backend dependencies, and the remaining optional simulation lock now resolves `pillow==10.4.0`.", + "local_review": { + "status": "covered", + "summary": "The underlying dependency-risk issue is addressed locally without cherry-picking: simulation/runtime packages moved behind an explicit optional install path instead of re-adding `unstructured` to the default backend dependencies, and the remaining optional simulation lock now resolves `pillow==10.4.0`.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 81, + "title": "feat: add configurable API timeout for slow local LLMs", + "url": "https://github.com/666ghj/MiroFish/pull/81", + "state": "open", + "created_at": "2026-03-08T02:34:41Z", + "updated_at": "2026-03-08T02:35:36Z", + "closed_at": null, + "merged_at": null, + "head": "fix/issue-58-configurable-timeout", + "head_ref_name": "fix/issue-58-configurable-timeout", + "head_sha": "92efb3616f40027ade6ed2b7789bc27e3ab036f8", + "head_repo": "JasonOA888/MiroFish", + "head_clone_url": "https://github.com/JasonOA888/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "LLM API", + "size:XS" + ], + "author": "JasonOA888", + "body_excerpt": "## Summary Fixes #58 - 允许用户配置API超时时间以支持响应较慢的本地大模型(如Ollama)。 ## Changes - 添加 `VITE_API_TIMEOUT` 环境变量支持 - 默认保持300000ms(5分钟) - 用户可根据需要增加超时时间 ## Usage 在 `.env` 文件中添加: ```bash # 本地大模型响应较慢时增加超时时间 VITE_API_TIMEOUT=600000 # 10分钟 ``` ## Testing - 默认值300000ms正常工作 - 配置后使用配置的值 Fixes #58", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-81", + "mirror_ref": "origin/mirror/upstream-pr-81", + "local_coverage": { + "number": 81, + "status": "landed", + "summary": "Landed locally: frontend API timeout is configurable for slow local and OpenAI-compatible backends." + }, + "local_status": "landed", + "local_summary": "Landed locally: frontend API timeout is configurable for slow local and OpenAI-compatible backends.", + "triage_status": "landed", + "summary": "Landed locally: frontend API timeout is configurable for slow local and OpenAI-compatible backends.", + "coverage_status": "landed", + "coverage_summary": "Landed locally: frontend API timeout is configurable for slow local and OpenAI-compatible backends.", + "local_review": { + "status": "landed", + "summary": "Landed locally: frontend API timeout is configurable for slow local and OpenAI-compatible backends.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 13, + "title": "feat: v0.2.0-beta - History UI, resume/cache, native tool calls (开发预览版)", + "url": "https://github.com/666ghj/MiroFish/pull/13", + "state": "closed", + "created_at": "2026-01-03T08:33:03Z", + "updated_at": "2026-03-06T07:48:07Z", + "closed_at": "2026-03-06T07:48:07Z", + "merged_at": null, + "head": "feat/local-resume-history-smoke", + "head_ref_name": "feat/local-resume-history-smoke", + "head_sha": "9faf23821ff842e817bcdede409e8166bb139641", + "head_repo": "martin0359/MiroFish", + "head_clone_url": "https://github.com/martin0359/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "dirty", + "labels": [], + "author": "martin0359", + "body_excerpt": "# 功能更新与使用说明(v0.2.0-beta 开发预览版) > ⚠️ **注意**: 这是开发预览版本,功能可能不稳定,仅供测试和反馈使用。 ## 你会看到的主要变化 - **History 页面**:新增 `/history`,按 **Project → Simulation → Report** 的嵌套关系浏览历史,并可一键跳转到 Step2/Step3/Report/Chat。 - **断点续跑(报告)**: - `Continue` 会在同一个 `report_id` 上从未完成章节继续生成(不重复生成已完成章节)。 - `Regenerate` 会为同一个 `simulation_id` 创建新的 `report_id`(旧报告保留在历史中)。 - **Interview 环境保活(关键)**:报告里的 `interview_agents` 需要模拟进入 **waiting/…", + "comment_count": 1, + "review_comment_count": 0, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-01-22T10:35:32Z", + "updated_at": "2026-01-22T10:35:32Z", + "url": "https://github.com/666ghj/MiroFish/pull/13#issuecomment-3783680201", + "body_excerpt": "<img width=\"2204\" height=\"1348\" alt=\"image\" src=\"https://github.com/user-attachments/assets/3c5349c2-cec7-4f51-901c-b1006b1199cc\" /> 现在通过这种卡片展开的方式提供了history查看,Interview环境的恢复可能需要从算法层面好好做优化,还需要一些时间" + } + ], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-13", + "mirror_ref": "origin/mirror/upstream-pr-13", + "triage_status": "untracked", + "summary": "# 功能更新与使用说明(v0.2.0-beta 开发预览版) > ⚠️ **注意**: 这是开发预览版本,功能可能不稳定,仅供测试和反馈使用。 ## 你会看到的主要变化 - **History 页面**:新增 `/history`,按 **Project → Simulation → Report** 的嵌套关系浏览历史,并可一键跳转到 Step2/Step3/Report/Chat。 - **断点续跑(报告)**: - `Continue` 会在同一个 `report_id` 上从未完成章节继续生成(不重复生成已完成章节)。 - `Regenerate` 会为同一个 `simulation_id` 创建新的 `report_id`(旧报告保留在历史中)。 - **Interview 环境保活(关键)**:报告里的 `interview_agents` 需要模拟进入 **waiting/…", + "coverage_status": "untracked", + "coverage_summary": "# 功能更新与使用说明(v0.2.0-beta 开发预览版) > ⚠️ **注意**: 这是开发预览版本,功能可能不稳定,仅供测试和反馈使用。 ## 你会看到的主要变化 - **History 页面**:新增 `/history`,按 **Project → Simulation → Report** 的嵌套关系浏览历史,并可一键跳转到 Step2/Step3/Report/Chat。 - **断点续跑(报告)**: - `Continue` 会在同一个 `report_id` 上从未完成章节继续生成(不重复生成已完成章节)。 - `Regenerate` 会为同一个 `simulation_id` 创建新的 `report_id`(旧报告保留在历史中)。 - **Interview 环境保活(关键)**:报告里的 `interview_agents` 需要模拟进入 **waiting/…", + "local_review": { + "status": "unreviewed", + "summary": "# 功能更新与使用说明(v0.2.0-beta 开发预览版) > ⚠️ **注意**: 这是开发预览版本,功能可能不稳定,仅供测试和反馈使用。 ## 你会看到的主要变化 - **History 页面**:新增 `/history`,按 **Project → Simulation → Report** 的嵌套关系浏览历史,并可一键跳转到 Step2/Step3/Report/Chat。 - **断点续跑(报告)**: - `Continue` 会在同一个 `report_id` 上从未完成章节继续生成(不重复生成已完成章节)。 - `Regenerate` 会为同一个 `simulation_id` 创建新的 `report_id`(旧报告保留在历史中)。 - **Interview 环境保活(关键)**:报告里的 `interview_agents` 需要模拟进入 **waiting/…", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 74, + "title": "fix: replace 4 bare excepts with except Exception", + "url": "https://github.com/666ghj/MiroFish/pull/74", + "state": "open", + "created_at": "2026-02-25T03:01:43Z", + "updated_at": "2026-02-25T09:20:00Z", + "closed_at": null, + "merged_at": null, + "head": "fix/bare-excepts", + "head_ref_name": "fix/bare-excepts", + "head_sha": "1612dfe017c5bebc963ae2838096b66ce134ba05", + "head_repo": "haosenwang1018/MiroFish", + "head_clone_url": "https://github.com/haosenwang1018/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "size:XS" + ], + "author": "haosenwang1018", + "body_excerpt": "Bare `except:` → `except Exception:` in 3 backend files (4 sites).", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-74", + "mirror_ref": "origin/mirror/upstream-pr-74", + "local_coverage": { + "number": 74, + "status": "landed", + "summary": "Landed locally: bare `except:` clauses in the reviewed JSON/simulation paths were narrowed to `except Exception:`." + }, + "local_status": "landed", + "local_summary": "Landed locally: bare `except:` clauses in the reviewed JSON/simulation paths were narrowed to `except Exception:`.", + "triage_status": "landed", + "summary": "Landed locally: bare `except:` clauses in the reviewed JSON/simulation paths were narrowed to `except Exception:`.", + "coverage_status": "landed", + "coverage_summary": "Landed locally: bare `except:` clauses in the reviewed JSON/simulation paths were narrowed to `except Exception:`.", + "local_review": { + "status": "landed", + "summary": "Landed locally: bare `except:` clauses in the reviewed JSON/simulation paths were narrowed to `except Exception:`.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 73, + "title": "fix: Handle string entities/edges in _validate_and_process", + "url": "https://github.com/666ghj/MiroFish/pull/73", + "state": "open", + "created_at": "2026-02-20T12:41:04Z", + "updated_at": "2026-02-20T12:42:03Z", + "closed_at": null, + "merged_at": null, + "head": "fix-ontology-validation", + "head_ref_name": "fix-ontology-validation", + "head_sha": "6fce4705ff255915ccdb76f9402983fe43cbc9cd", + "head_repo": "calvinguo721/MiroFish", + "head_clone_url": "https://github.com/calvinguo721/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "LLM API", + "size:S" + ], + "author": "calvinguo721", + "body_excerpt": "## Problem When LLM returns malformed JSON where `entity_types` or `edge_types` contain strings instead of dictionaries, the `_validate_and_process` method crashes with: ``` TypeError: 'str' object does not support item assignment ``` ## Solution Added type checking in `_validate_and_process`: - If entity/edge is a string, wrap it into proper dict format - Skip invalid (non-dict) entries to preve…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-73", + "mirror_ref": "origin/mirror/upstream-pr-73", + "local_coverage": { + "number": 73, + "status": "landed", + "summary": "Landed locally: malformed ontology entity and edge items are sanitized before fallback injection." + }, + "local_status": "landed", + "local_summary": "Landed locally: malformed ontology entity and edge items are sanitized before fallback injection.", + "triage_status": "landed", + "summary": "Landed locally: malformed ontology entity and edge items are sanitized before fallback injection.", + "coverage_status": "landed", + "coverage_summary": "Landed locally: malformed ontology entity and edge items are sanitized before fallback injection.", + "local_review": { + "status": "landed", + "summary": "Landed locally: malformed ontology entity and edge items are sanitized before fallback injection.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 70, + "title": "windows安装程序打包", + "url": "https://github.com/666ghj/MiroFish/pull/70", + "state": "open", + "created_at": "2026-02-10T14:47:00Z", + "updated_at": "2026-02-16T20:17:26Z", + "closed_at": null, + "merged_at": null, + "head": "main", + "head_ref_name": "main", + "head_sha": "406ac1df624220a154552548173118769098b14f", + "head_repo": "Jonah-Wu23/MiroFish_exe", + "head_clone_url": "https://github.com/Jonah-Wu23/MiroFish_exe.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "enhancement", + "size:XXL" + ], + "author": "Jonah-Wu23", + "body_excerpt": "### 我做的改动 - 将原项目打包为 **Windows 可安装程序(.exe)** - 安装过程中可直接 **填写 API Key(按提示输入)** - 安装完成后可 **双击主程序自动打开浏览器界面** - 实现 Windows 端 **一键安装与运行体验** ### 打包方法: 使用嵌入式 Python 模式打包(体积较小,适合大多数情况): ```powershell .\\installer\\build.ps1 ``` 也可以使用PyInstaller 打包: ```powershell .\\installer\\build.ps1 -PyInstaller ``` > ⚠️ 注意:PyInstaller 模式打包时间长,输出文件可能超过 1GB 如果只需要部分步骤,可以使用参数跳过: ```powershell # 跳过前端构建(如果前端没有修改) .\\installer\\bu…", + "comment_count": 1, + "review_comment_count": 0, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-02-16T20:17:26Z", + "updated_at": "2026-02-16T20:17:26Z", + "url": "https://github.com/666ghj/MiroFish/pull/70#issuecomment-3910367836", + "body_excerpt": "👍👍" + } + ], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-70", + "mirror_ref": "origin/mirror/upstream-pr-70", + "local_coverage": { + "number": 70, + "status": "not_safe", + "summary": "Not safe to cherry-pick: the Windows installer flow assumes a repo topology that does not match the current `run.py` plus built-frontend layout." + }, + "local_status": "not_safe", + "local_summary": "Not safe to cherry-pick: the Windows installer flow assumes a repo topology that does not match the current `run.py` plus built-frontend layout.", + "triage_status": "not_safe", + "summary": "Not safe to cherry-pick: the Windows installer flow assumes a repo topology that does not match the current `run.py` plus built-frontend layout.", + "coverage_status": "not_safe", + "coverage_summary": "Not safe to cherry-pick: the Windows installer flow assumes a repo topology that does not match the current `run.py` plus built-frontend layout.", + "local_review": { + "status": "not_safe", + "summary": "Not safe to cherry-pick: the Windows installer flow assumes a repo topology that does not match the current `run.py` plus built-frontend layout.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 33, + "title": "增加docker compose部署支持", + "url": "https://github.com/666ghj/MiroFish/pull/33", + "state": "closed", + "created_at": "2026-01-17T03:45:37Z", + "updated_at": "2026-01-29T01:24:09Z", + "closed_at": "2026-01-29T01:24:09Z", + "merged_at": null, + "head": "main", + "head_ref_name": "main", + "head_sha": "6c4954aed1315fc9aeb3d79bec1c4d69f89c28be", + "head_repo": "zouyonghe/MiroFish", + "head_clone_url": "https://github.com/zouyonghe/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "dirty", + "labels": [], + "author": "zouyonghe", + "body_excerpt": "## 变更摘要 - 增加后端/前端 Dockerfile 与 compose 配置,支持本地一键启动 - Vite 开发代理支持通过 VITE_API_BASE_URL 配置后端地址 - README 补充 Docker Compose 启动说明 ## 测试 - docker compose up --build -d - docker compose logs -f", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-33", + "mirror_ref": "origin/mirror/upstream-pr-33", + "triage_status": "untracked", + "summary": "## 变更摘要 - 增加后端/前端 Dockerfile 与 compose 配置,支持本地一键启动 - Vite 开发代理支持通过 VITE_API_BASE_URL 配置后端地址 - README 补充 Docker Compose 启动说明 ## 测试 - docker compose up --build -d - docker compose logs -f", + "coverage_status": "untracked", + "coverage_summary": "## 变更摘要 - 增加后端/前端 Dockerfile 与 compose 配置,支持本地一键启动 - Vite 开发代理支持通过 VITE_API_BASE_URL 配置后端地址 - README 补充 Docker Compose 启动说明 ## 测试 - docker compose up --build -d - docker compose logs -f", + "local_review": { + "status": "unreviewed", + "summary": "## 变更摘要 - 增加后端/前端 Dockerfile 与 compose 配置,支持本地一键启动 - Vite 开发代理支持通过 VITE_API_BASE_URL 配置后端地址 - README 补充 Docker Compose 启动说明 ## 测试 - docker compose up --build -d - docker compose logs -f", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 38, + "title": "feat: Add support for Anthropic SDK (Claude) and multi-provider switching", + "url": "https://github.com/666ghj/MiroFish/pull/38", + "state": "open", + "created_at": "2026-01-20T09:56:00Z", + "updated_at": "2026-01-24T16:25:40Z", + "closed_at": null, + "merged_at": null, + "head": "feat/anthropic-sdk", + "head_ref_name": "feat/anthropic-sdk", + "head_sha": "9b795e1bd9a4f57feee53354328dc60acab9fd63", + "head_repo": "SmartisanNaive/MiroFish", + "head_clone_url": "https://github.com/SmartisanNaive/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "dirty", + "labels": [], + "author": "SmartisanNaive", + "body_excerpt": "This PR introduces native support for the **Anthropic (Claude)** SDK to MiroFish! While the project already supports various models via the OpenAI-compatible interface, integrating the official Anthropic SDK allows us to better leverage the capabilities of models like Claude 4 Sonnet. It also ensures compatibility with providers that follow the Anthropic protocol (e.g., Zhipu AI's GLM-4.7 endpoin…", + "comment_count": 2, + "review_comment_count": 0, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-01-24T16:20:22Z", + "updated_at": "2026-01-24T16:20:22Z", + "url": "https://github.com/666ghj/MiroFish/pull/38#issuecomment-3794970052", + "body_excerpt": "I appreciate it, but considering that supporting an additional API format would bring unnecessary workload to the subsequent update plans, I will keep this PR to provide ideas for everyone, but it will not be merged before the release of v…" + }, + { + "author": "SmartisanNaive", + "created_at": "2026-01-24T16:25:40Z", + "updated_at": "2026-01-24T16:25:40Z", + "url": "https://github.com/666ghj/MiroFish/pull/38#issuecomment-3794988321", + "body_excerpt": "Thanks for reading , this is a great project.Your project has brought a lot of inspiration to our researchers working on multi-agent applications, thanks again(●'◡'●) ---Original--- From: ***@***.***> Date: Sun, Jan 25, 2026 00:20 AM To…" + } + ], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-38", + "mirror_ref": "origin/mirror/upstream-pr-38", + "local_coverage": { + "number": 38, + "status": "not_safe", + "summary": "Not safe to cherry-pick: it expands the backend API surface to Anthropic-specific protocol work while this branch is intentionally standardizing on OpenAI-compatible gateways." + }, + "local_status": "not_safe", + "local_summary": "Not safe to cherry-pick: it expands the backend API surface to Anthropic-specific protocol work while this branch is intentionally standardizing on OpenAI-compatible gateways.", + "triage_status": "not_safe", + "summary": "Not safe to cherry-pick: it expands the backend API surface to Anthropic-specific protocol work while this branch is intentionally standardizing on OpenAI-compatible gateways.", + "coverage_status": "not_safe", + "coverage_summary": "Not safe to cherry-pick: it expands the backend API surface to Anthropic-specific protocol work while this branch is intentionally standardizing on OpenAI-compatible gateways.", + "local_review": { + "status": "not_safe", + "summary": "Not safe to cherry-pick: it expands the backend API surface to Anthropic-specific protocol work while this branch is intentionally standardizing on OpenAI-compatible gateways.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 25, + "title": "添加 Docker 支持和 CI/CD 自动化", + "url": "https://github.com/666ghj/MiroFish/pull/25", + "state": "closed", + "created_at": "2026-01-15T02:44:29Z", + "updated_at": "2026-01-22T10:32:32Z", + "closed_at": "2026-01-22T10:32:32Z", + "merged_at": null, + "head": "main", + "head_ref_name": "main", + "head_sha": "f15e2d67ed14e5842240f4bcc21ba69eb5fdce96", + "head_repo": "Deroino/MiroFish", + "head_clone_url": "https://github.com/Deroino/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "dirty", + "labels": [], + "author": "Deroino", + "body_excerpt": "为 MiroFish 添加了完整的 Docker 支持和 GitHub Actions CI/CD 流程,方便部署和自动化构建。 ## 主要改动 ### 新增文件 - **Dockerfile**: 多阶段构建配置(前端 + 后端) - **docker-compose.yml**: 本地开发模式 - **docker-compose.pull.yml**: 生产环境镜像拉取模式 - **.dockerignore**: 优化构建上下文 - **docker/nginx.conf**: Nginx 反向代理配置 - **.github/workflows/docker-build.yml**: GitHub Actions 自动构建工作流 - **DOCKER.md**: 详细的 Docker 部署文档 ### 功能特性 - 支持多平台构建(linux/amd64, linux/arm6…", + "comment_count": 1, + "review_comment_count": 0, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-01-22T10:32:27Z", + "updated_at": "2026-01-22T10:32:27Z", + "url": "https://github.com/666ghj/MiroFish/pull/25#issuecomment-3783667550", + "body_excerpt": "今天上了docker,现在可以看一下,感谢pr以及对mirofish对支持呀~" + } + ], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-25", + "mirror_ref": "origin/mirror/upstream-pr-25", + "triage_status": "untracked", + "summary": "为 MiroFish 添加了完整的 Docker 支持和 GitHub Actions CI/CD 流程,方便部署和自动化构建。 ## 主要改动 ### 新增文件 - **Dockerfile**: 多阶段构建配置(前端 + 后端) - **docker-compose.yml**: 本地开发模式 - **docker-compose.pull.yml**: 生产环境镜像拉取模式 - **.dockerignore**: 优化构建上下文 - **docker/nginx.conf**: Nginx 反向代理配置 - **.github/workflows/docker-build.yml**: GitHub Actions 自动构建工作流 - **DOCKER.md**: 详细的 Docker 部署文档 ### 功能特性 - 支持多平台构建(linux/amd64, linux/arm6…", + "coverage_status": "untracked", + "coverage_summary": "为 MiroFish 添加了完整的 Docker 支持和 GitHub Actions CI/CD 流程,方便部署和自动化构建。 ## 主要改动 ### 新增文件 - **Dockerfile**: 多阶段构建配置(前端 + 后端) - **docker-compose.yml**: 本地开发模式 - **docker-compose.pull.yml**: 生产环境镜像拉取模式 - **.dockerignore**: 优化构建上下文 - **docker/nginx.conf**: Nginx 反向代理配置 - **.github/workflows/docker-build.yml**: GitHub Actions 自动构建工作流 - **DOCKER.md**: 详细的 Docker 部署文档 ### 功能特性 - 支持多平台构建(linux/amd64, linux/arm6…", + "local_review": { + "status": "unreviewed", + "summary": "为 MiroFish 添加了完整的 Docker 支持和 GitHub Actions CI/CD 流程,方便部署和自动化构建。 ## 主要改动 ### 新增文件 - **Dockerfile**: 多阶段构建配置(前端 + 后端) - **docker-compose.yml**: 本地开发模式 - **docker-compose.pull.yml**: 生产环境镜像拉取模式 - **.dockerignore**: 优化构建上下文 - **docker/nginx.conf**: Nginx 反向代理配置 - **.github/workflows/docker-build.yml**: GitHub Actions 自动构建工作流 - **DOCKER.md**: 详细的 Docker 部署文档 ### 功能特性 - 支持多平台构建(linux/amd64, linux/arm6…", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 44, + "title": "添加 docker 与 GitHub Actions", + "url": "https://github.com/666ghj/MiroFish/pull/44", + "state": "closed", + "created_at": "2026-01-21T02:43:22Z", + "updated_at": "2026-01-22T10:31:52Z", + "closed_at": "2026-01-22T10:31:52Z", + "merged_at": null, + "head": "main", + "head_ref_name": "main", + "head_sha": "435749aa85aee9dd435fc264e8fb3ab5d77dca24", + "head_repo": "moonhalf-nostar/MiroFish", + "head_clone_url": "https://github.com/moonhalf-nostar/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "dirty", + "labels": [], + "author": "moonhalf-nostar", + "body_excerpt": "添加 Dockerfile 与 docker-compose.yml 文件 添加 Github Actions 以打包 docker 镜像", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-44", + "mirror_ref": "origin/mirror/upstream-pr-44", + "triage_status": "untracked", + "summary": "添加 Dockerfile 与 docker-compose.yml 文件 添加 Github Actions 以打包 docker 镜像", + "coverage_status": "untracked", + "coverage_summary": "添加 Dockerfile 与 docker-compose.yml 文件 添加 Github Actions 以打包 docker 镜像", + "local_review": { + "status": "unreviewed", + "summary": "添加 Dockerfile 与 docker-compose.yml 文件 添加 Github Actions 以打包 docker 镜像", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 49, + "title": "记忆图谱本地化实现", + "url": "https://github.com/666ghj/MiroFish/pull/49", + "state": "open", + "created_at": "2026-01-22T06:42:21Z", + "updated_at": "2026-01-22T06:42:21Z", + "closed_at": null, + "merged_at": null, + "head": "feat/local", + "head_ref_name": "feat/local", + "head_sha": "b04d7f7e4fb014f376ac4085a3d6a83929a61cc3", + "head_repo": "Momoyeyu/MiroFish", + "head_clone_url": "https://github.com/Momoyeyu/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "dirty", + "labels": [], + "author": "Momoyeyu", + "body_excerpt": "实现以下了能力: 1. 基于[MiroFishOpt](https://github.com/jwc19890114/MiroFishOpt),实现本地图谱构建 2. 参考Zep论文,实现记忆图谱的更新 3. 实现图谱节点的去重 存在不足: 1. 尚未实现Zep的时序能力,无法识别并标记过期的记忆 2. 图谱的丰富节点程度不如Zep 3. 使用大模型进行节点去重时的运行速度较慢", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-49", + "mirror_ref": "origin/mirror/upstream-pr-49", + "local_coverage": { + "number": 49, + "status": "not_safe", + "summary": "Not safe to cherry-pick: it introduces a large local graph backend without targeted regression coverage and is superseded conceptually by the broader backend-abstraction follow-up." + }, + "local_status": "not_safe", + "local_summary": "Not safe to cherry-pick: it introduces a large local graph backend without targeted regression coverage and is superseded conceptually by the broader backend-abstraction follow-up.", + "triage_status": "not_safe", + "summary": "Not safe to cherry-pick: it introduces a large local graph backend without targeted regression coverage and is superseded conceptually by the broader backend-abstraction follow-up.", + "coverage_status": "not_safe", + "coverage_summary": "Not safe to cherry-pick: it introduces a large local graph backend without targeted regression coverage and is superseded conceptually by the broader backend-abstraction follow-up.", + "local_review": { + "status": "not_safe", + "summary": "Not safe to cherry-pick: it introduces a large local graph backend without targeted regression coverage and is superseded conceptually by the broader backend-abstraction follow-up.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 6, + "title": "新增 /projects 历史项目页、默认仅显示环境搭建完成项目、点击进入环境/项目feat(frontend): add projects history page", + "url": "https://github.com/666ghj/MiroFish/pull/6", + "state": "closed", + "created_at": "2025-12-26T15:21:46Z", + "updated_at": "2026-01-20T09:52:11Z", + "closed_at": "2026-01-20T09:52:11Z", + "merged_at": null, + "head": "feat/projects-history", + "head_ref_name": "feat/projects-history", + "head_sha": "7d665f5a3c27b65306bf2edbccb67f2177a6871b", + "head_repo": "jwc19890114/MiroFish", + "head_clone_url": "https://github.com/jwc19890114/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [], + "author": "jwc19890114", + "body_excerpt": "新增 /projects 历史项目页、默认仅显示环境搭建完成项目、点击进入环境/项目", + "comment_count": 1, + "review_comment_count": 4, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2025-12-31T03:30:04Z", + "updated_at": "2025-12-31T03:30:04Z", + "url": "https://github.com/666ghj/MiroFish/pull/6#issuecomment-3701347901", + "body_excerpt": "心有灵犀,这个也是我最近在写的东西,我来具体看一下你的代码,不过可能不会合并,因为这个项目可能暂时Contributors保持在一个人,感谢老哥的贡献,没有找到你的联系方式,方便留一个邮箱吗" + } + ], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-6", + "mirror_ref": "origin/mirror/upstream-pr-6", + "triage_status": "untracked", + "summary": "新增 /projects 历史项目页、默认仅显示环境搭建完成项目、点击进入环境/项目", + "coverage_status": "untracked", + "coverage_summary": "新增 /projects 历史项目页、默认仅显示环境搭建完成项目、点击进入环境/项目", + "local_review": { + "status": "unreviewed", + "summary": "新增 /projects 历史项目页、默认仅显示环境搭建完成项目、点击进入环境/项目", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 15, + "title": "fix(frontend): handle simulation failed status", + "url": "https://github.com/666ghj/MiroFish/pull/15", + "state": "open", + "created_at": "2026-01-06T06:45:39Z", + "updated_at": "2026-01-06T06:53:51Z", + "closed_at": null, + "merged_at": null, + "head": "fix/frontend-simulation-error-handling", + "head_ref_name": "fix/frontend-simulation-error-handling", + "head_sha": "6a8bc97da085f67ae0edc0e62425e0375d9f8506", + "head_repo": "tt-a1i/MiroFish", + "head_clone_url": "https://github.com/tt-a1i/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [], + "author": "tt-a1i", + "body_excerpt": "## Summary - Add check for `runner_status === 'failed'` in `fetchRunStatus()` to properly display error messages when simulation fails - Previously the UI would stay in \"running\" state indefinitely when simulation failed Closes #14 ## AI Assistance Disclosure I used Codex to review the changes, sanity-check the implementation against existing patterns, and help spot potential edge cases.", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-15", + "mirror_ref": "origin/mirror/upstream-pr-15", + "local_coverage": { + "number": 15, + "status": "landed", + "summary": "Landed locally: Step 3 now surfaces failed simulation status instead of polling forever.", + "local_refs": [ + "frontend/src/components/Step3Simulation.vue" + ], + "validation": [ + "frontend: npm run build" + ] + }, + "local_status": "landed", + "local_summary": "Landed locally: Step 3 now surfaces failed simulation status instead of polling forever.", + "triage_status": "landed", + "summary": "Landed locally: Step 3 now surfaces failed simulation status instead of polling forever.", + "coverage_status": "landed", + "coverage_summary": "Landed locally: Step 3 now surfaces failed simulation status instead of polling forever.", + "local_review": { + "status": "landed", + "summary": "Landed locally: Step 3 now surfaces failed simulation status instead of polling forever.", + "local_refs": [ + "frontend/src/components/Step3Simulation.vue" + ], + "validation": [ + "frontend: npm run build" + ], + "notes": null + } + }, + { + "number": 12, + "title": "Backend terminal color change", + "url": "https://github.com/666ghj/MiroFish/pull/12", + "state": "closed", + "created_at": "2025-12-30T10:04:33Z", + "updated_at": "2025-12-30T10:07:39Z", + "closed_at": "2025-12-30T10:05:36Z", + "merged_at": "2025-12-30T10:05:36Z", + "head": "cursor/backend-terminal-color-change-14fd", + "head_ref_name": "cursor/backend-terminal-color-change-14fd", + "head_sha": "84dd2cbcca2848b29942162cbd435a663bb735f5", + "head_repo": "666ghj/MiroFish", + "head_clone_url": "https://github.com/666ghj/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "unknown", + "labels": [], + "author": "666ghj", + "body_excerpt": "Update `dev` script to change backend terminal prefix color from yellow to green. --- <a href=\"https://cursor.com/background-agent?bcId=bc-a5b64111-4551-4e51-bef6-3522fd945bdd\"><picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"https://cursor.com/open-in-cursor-dark.svg\"><source media=\"(prefers-color-scheme: light)\" srcset=\"https://cursor.com/open-in-cursor-light.svg\"><img alt=\"Open in…", + "comment_count": 1, + "review_comment_count": 0, + "recent_comments": [ + { + "author": "cursor[bot]", + "created_at": "2025-12-30T10:04:34Z", + "updated_at": "2025-12-30T10:04:34Z", + "url": "https://github.com/666ghj/MiroFish/pull/12#issuecomment-3698898765", + "body_excerpt": "Cursor Agent can help with this pull request. Just `@cursor` in comments and I'll start working on changes in this branch. <sub>[Learn more](https://cursor.com/docs/background-agent/web-and-mobile) about Cursor Agents</sub>" + } + ], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-12", + "mirror_ref": "origin/mirror/upstream-pr-12", + "triage_status": "untracked", + "summary": "Update `dev` script to change backend terminal prefix color from yellow to green. --- <a href=\"https://cursor.com/background-agent?bcId=bc-a5b64111-4551-4e51-bef6-3522fd945bdd\"><picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"https://cursor.com/open-in-cursor-dark.svg\"><source media=\"(prefers-color-scheme: light)\" srcset=\"https://cursor.com/open-in-cursor-light.svg\"><img alt=\"Open in…", + "coverage_status": "untracked", + "coverage_summary": "Update `dev` script to change backend terminal prefix color from yellow to green. --- <a href=\"https://cursor.com/background-agent?bcId=bc-a5b64111-4551-4e51-bef6-3522fd945bdd\"><picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"https://cursor.com/open-in-cursor-dark.svg\"><source media=\"(prefers-color-scheme: light)\" srcset=\"https://cursor.com/open-in-cursor-light.svg\"><img alt=\"Open in…", + "local_review": { + "status": "unreviewed", + "summary": "Update `dev` script to change backend terminal prefix color from yellow to green. --- <a href=\"https://cursor.com/background-agent?bcId=bc-a5b64111-4551-4e51-bef6-3522fd945bdd\"><picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"https://cursor.com/open-in-cursor-dark.svg\"><source media=\"(prefers-color-scheme: light)\" srcset=\"https://cursor.com/open-in-cursor-light.svg\"><img alt=\"Open in…", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 10, + "title": "Codebase bug resolution", + "url": "https://github.com/666ghj/MiroFish/pull/10", + "state": "closed", + "created_at": "2025-12-29T08:14:25Z", + "updated_at": "2025-12-30T09:59:16Z", + "closed_at": "2025-12-29T08:20:40Z", + "merged_at": null, + "head": "cursor/codebase-bug-resolution-07f6", + "head_ref_name": "cursor/codebase-bug-resolution-07f6", + "head_sha": "55b118668433139b6dec7b994e915b30777912ee", + "head_repo": "666ghj/MiroFish", + "head_clone_url": "https://github.com/666ghj/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": true, + "mergeable_state": "clean", + "labels": [], + "author": "666ghj", + "body_excerpt": "Fixes a path traversal vulnerability, a potential infinite loop in text chunking, and a memory leak in the TaskManager to improve security, stability, and performance. --- <a href=\"https://cursor.com/background-agent?bcId=bc-d42b0775-c487-403e-a9bf-70f257fe3ce8\"><picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"https://cursor.com/open-in-cursor-dark.svg\"><source media=\"(prefers-color-…", + "comment_count": 1, + "review_comment_count": 0, + "recent_comments": [ + { + "author": "cursor[bot]", + "created_at": "2025-12-29T08:14:26Z", + "updated_at": "2025-12-29T08:14:26Z", + "url": "https://github.com/666ghj/MiroFish/pull/10#issuecomment-3695802531", + "body_excerpt": "Cursor Agent can help with this pull request. Just `@cursor` in comments and I'll start working on changes in this branch. <sub>[Learn more](https://cursor.com/docs/background-agent/web-and-mobile) about Cursor Agents</sub>" + } + ], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-10", + "mirror_ref": "origin/mirror/upstream-pr-10", + "triage_status": "untracked", + "summary": "Fixes a path traversal vulnerability, a potential infinite loop in text chunking, and a memory leak in the TaskManager to improve security, stability, and performance. --- <a href=\"https://cursor.com/background-agent?bcId=bc-d42b0775-c487-403e-a9bf-70f257fe3ce8\"><picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"https://cursor.com/open-in-cursor-dark.svg\"><source media=\"(prefers-color-…", + "coverage_status": "untracked", + "coverage_summary": "Fixes a path traversal vulnerability, a potential infinite loop in text chunking, and a memory leak in the TaskManager to improve security, stability, and performance. --- <a href=\"https://cursor.com/background-agent?bcId=bc-d42b0775-c487-403e-a9bf-70f257fe3ce8\"><picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"https://cursor.com/open-in-cursor-dark.svg\"><source media=\"(prefers-color-…", + "local_review": { + "status": "unreviewed", + "summary": "Fixes a path traversal vulnerability, a potential infinite loop in text chunking, and a memory leak in the TaskManager to improve security, stability, and performance. --- <a href=\"https://cursor.com/background-agent?bcId=bc-d42b0775-c487-403e-a9bf-70f257fe3ce8\"><picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"https://cursor.com/open-in-cursor-dark.svg\"><source media=\"(prefers-color-…", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 1, + "title": " Windows 兼容性修复", + "url": "https://github.com/666ghj/MiroFish/pull/1", + "state": "closed", + "created_at": "2025-12-22T18:48:17Z", + "updated_at": "2025-12-30T09:58:27Z", + "closed_at": "2025-12-30T09:58:27Z", + "merged_at": null, + "head": "bugfix", + "head_ref_name": "bugfix", + "head_sha": "d80ee2b6bb4dc221c4f9d32c5459b76ebced06b3", + "head_repo": "huanchong-99/MiroFish", + "head_clone_url": "https://github.com/huanchong-99/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "dirty", + "labels": [], + "author": "huanchong-99", + "body_excerpt": "", + "comment_count": 2, + "review_comment_count": 5, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2025-12-26T10:00:51Z", + "updated_at": "2025-12-26T10:00:51Z", + "url": "https://github.com/666ghj/MiroFish/pull/1#issuecomment-3692617015", + "body_excerpt": "正在修复,感谢提供思路!" + }, + { + "author": "666ghj", + "created_at": "2025-12-30T09:58:24Z", + "updated_at": "2025-12-30T09:58:24Z", + "url": "https://github.com/666ghj/MiroFish/pull/1#issuecomment-3698876907", + "body_excerpt": "新版本修复完成了,可以拉一下最新代码看一下" + } + ], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-1", + "mirror_ref": "origin/mirror/upstream-pr-1", + "triage_status": "untracked", + "summary": "", + "coverage_status": "untracked", + "coverage_summary": "", + "local_review": { + "status": "unreviewed", + "summary": "", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 2, + "title": "Encoding display fix", + "url": "https://github.com/666ghj/MiroFish/pull/2", + "state": "closed", + "created_at": "2025-12-22T19:53:01Z", + "updated_at": "2025-12-26T10:00:16Z", + "closed_at": "2025-12-26T10:00:16Z", + "merged_at": null, + "head": "encoding-display-fix", + "head_ref_name": "encoding-display-fix", + "head_sha": "28a5212ce69b7da5bd57df4f307c779f2ad3d008", + "head_repo": "huanchong-99/MiroFish", + "head_clone_url": "https://github.com/huanchong-99/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "dirty", + "labels": [], + "author": "huanchong-99", + "body_excerpt": "修复终端中文乱码问题", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-2", + "mirror_ref": "origin/mirror/upstream-pr-2", + "triage_status": "untracked", + "summary": "修复终端中文乱码问题", + "coverage_status": "untracked", + "coverage_summary": "修复终端中文乱码问题", + "local_review": { + "status": "unreviewed", + "summary": "修复终端中文乱码问题", + "local_refs": [], + "validation": [], + "notes": null + } + } + ], + "fork_remote": "origin", + "mirror_issues_repo": "ivanzud/MiroFish" +} diff --git a/docs/upstream-all-summary.md b/docs/upstream-all-summary.md new file mode 100644 index 00000000..f198cb93 --- /dev/null +++ b/docs/upstream-all-summary.md @@ -0,0 +1,83 @@ +# Upstream Triage Snapshot + +- Repository: `666ghj/MiroFish` +- State filter: `all` +- Captured: `2026-03-12T04:03:26.339986+00:00` +- Issues: `96` total (`open=46`, `closed=50`) +- Pull requests: `54` total (`open=40`, `closed=14`) +- Mirrored in `origin`: `54` of `54` PR refs +- Mirrored in `ivanzud/MiroFish`: `96` of `96` issues +- Local issue coverage map: `docs/upstream-coverage.json` + +## Recently Updated Issues + +- #159 [open, mirror=#97] 太消耗zep了,为啥不考虑自建库呢? (enhancement) + - local coverage [tracked]: Tracked under beads issue `mirofish-zx6p`: another upstream request for a self-hosted or non-Zep graph backend is preserved locally, but implementing it safely still requires the broader graph-backend abstraction work already tracked under `mirofish-8eg` instead of wiring an ad hoc replacement into the current graph/simulation pipeline. + - zep的额度太低了,要真正进行分析,需要大量的Episode。能否考虑基于其他开源方案,重写zep部分? + - latest comment by `chrischeng192`: 你暂时可以看看[这里](https://github.com/666ghj/MiroFish/issues/56) +- #158 [open, mirror=#95] Are there any predictions that have been verified by subsequent events? (question) + - local coverage [partial]: README.md, README-EN.md, README-RU.md, README-KO.md, and README-JA.md now document a repo-native forecast verification workflow, Step 4 surfaces both the stable `report_id` and `simulation_id` with direct copy actions, the homepage history modal keeps those same references together for later review, and both views now also copy a single structured verification bundle so users can preserve the paired references in one paste. Exported Step 4 Markdown still embeds the report/simulation/graph references directly in the file header, and it now also includes stable local report paths plus a localized manual verification checklist so the saved artifact stays actionable outside the UI. Users can export that Markdown from both Step 4 and the saved-history modal or reuse the files under `backend/uploads/reports/<report_id>/` for later comparison against real-world outcomes. MiroFish still does not ship an automatic ground-truth ingester or scoring pipeline, so full backtesting remains tracked under beads issue `mirofish-gytl`. + - Awesome idea! I am wondering are there any predictions that have been verified by subsequent events? + - latest comment by `codetsang`: Not yet? Maybe you should give it a try and validate the results. BTW, this is a prediction tool, so there are many uncertainties involved. It should be used more as an analysis or decision-support tool rather than a strict predictor. +- #157 [open, mirror=#96] 如何删除不想要的记录 (question) + - local coverage [covered]: Homepage history now supports repo-native deletion of unwanted local records. The backend exposes `DELETE /api/simulation/history/<simulation_id>` to remove a simulation's persisted local directory, cascade-delete its attached local reports, and prune the project metadata when no sibling simulations remain, while refusing deletion for active runs. The history modal now exposes a localized delete action that calls that endpoint directly. + - 比如我想删除 <img width="1835" height="775" alt="Image" src="https://github.com/user-attachments/assets/12332bbc-f309-497b-a352-f0d15289042e" />这两个,怎么删除呢 +- #156 [open, mirror=#94] 能不能不要画zep图?我只要推演和角色互动 (enhancement) + - local coverage [tracked]: Tracked under beads issue `mirofish-gd5z`: upstream wants a simulation/report workflow that avoids Zep graph dependency entirely. The config-status payload and frontend backend diagnostics now expose a capability matrix that separates the direct `OPENAI_*` / Codex-compatible LLM path from Zep-gated Step 1 graph build and graph-backed Step 4 tooling, `/api/report/generate` now fails early with the same structured backend-config payload instead of launching a doomed async Step 4 task, and the frontend now also exposes a simulation-only Step 5 route plus Step 3/Step 4 CTAs so users can continue directly into role interaction without a report when only `ZEP_API_KEY` is missing. Full non-Zep simulation-only execution still needs a dedicated backend-architecture change. + - zep免费额度轻松就用完了,然后流程卡4/5在生成报告上面 +- #154 [open, mirror=#93] Profile serialization crashes when LLM returns structured bio/persona fields (no labels) + - local coverage [covered]: Profile serialization now tolerates structured LLM output instead of crashing when `bio`, `persona`, `country`, `profession`, or `interested_topics` arrive as dict/list values. `OasisAgentProfile` normalizes those mixed types at construction time and the Reddit/Twitter serializers defensively coerce them again before slicing or string replacement, so simulation preparation no longer fails during profile save with `KeyError: slice(None, 150, None)`. + - ## Summary When profile generation returns structured JSON objects for fields like `bio`, `persona`, or `country`, MiroFish can fail during profile serialization before config generation starts. ## Reproduction context Observed on a live run with: - simulation_id: `sim_e69a946b6158` - graph_id: `mirofish_a39b5f10127f4744` - entities_count: `91` - status in state file: `failed` - error in state fi… + - latest comment by `dosubot[bot]`: <!-- Greeting --> Hi @ygh1254! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the MiroFish team. <!-- Answer --> Your analysis is spot on. Looking at the code, the root cause is exactly as you described — the [`OasisAgentProfile`… +- #153 [open, mirror=#92] npm run setup:all安装时一直报 pillow` (v10.3.0) 的错 (question) + - local coverage [covered]: The current branch no longer reproduces a `pillow` build during the default `npm run setup:all` core install path. `setup:backend` now maps to a plain `uv sync` of the core graph/report/OpenAI-compatible backend dependencies, while the heavyweight simulation runtime remains behind the separate `setup:backend:simulation` entrypoint. A Windows + Python 3.13 dry-run of `uv sync --frozen` against the current lockfile does not attempt to install `pillow` at all, and the optional simulation lock now resolves `pillow==10.4.0` instead of `10.3.0`. + - Resolved 188 packages in 5.27s Built mirofish-backend @ file:///D:/MiroFish/backend x Failed to build `pillow==10.3.0` |-> The build backend returned an error `-> Call to `backend.build_wheel` failed (exit code: 1) [stderr] Traceback (most recent call last): File "<string>", line 14, in <module> requires = get_requires_for_build({}) File "C:\Users\Administrator\AppData\Local\uv\cache\builds-v0\.t… +- #150 [open, mirror=#91] Bug: Hardcoded 'reddit' platform default causes silent data loss for Twitter-only simulations (no labels) + - local coverage [covered]: Simulation data retrieval now resolves the active platform from `SimulationState` instead of silently defaulting to Reddit when Twitter/X is the only enabled platform, and `SimulationManager.get_profiles()` now reads Twitter profile CSV files correctly as well. That prevents empty profile/post responses in Twitter-only simulations even when older callers still pass the historical `reddit` default. + - ## Summary When a simulation is created with Twitter-only configuration (`enable_reddit=false`), all data retrieval APIs silently return empty results because they default to looking up Reddit data. No error is raised — the user sees an empty UI with no indication of what went wrong. ## Root Cause The platform parameter defaults to `'reddit'` in 11+ locations across the codebase. When a Twitter-o… +- #148 [closed, mirror=#89] Request failed with status code 504 (LLM API) + - local coverage [covered]: Interview env liveness now validates the persisted runner state and recorded process PID instead of trusting stale env_status.json alone, so Step 5 world-agent chat fails fast with the existing closed-environment guidance instead of hanging into a 504 when the simulation process has already exited. + - 完成report后,进入深度对话,在Interactive Tools中,与Report agent对话是正常的,但是与世界中任意个体对话则报错:“抱歉,发生了错误: Request failed with status code 504“。 + - latest comment by `dosubot[bot]`: <!-- Answer --> 这个504错误是因为**与世界个体对话需要模拟环境保持运行状态**,而Report Agent对话则不需要。 具体原因: - **Report Agent对话**使用的是 `/api/report/chat` 端点,它[独立创建ReportAgent实例,不依赖模拟环境](https://github.com/666ghj/MiroFish/blob/985f89f49acbb44ee14d9d680682c741a44eeebe/backe… +- #149 [open, mirror=#90] 一直卡在 Waiting for agent actions (question) + - local coverage [covered]: Step 3 now reconciles stale persisted `running` states when the worker PID is gone, and the detailed status payload exposes compact simulation-log diagnostics while waiting for the first actions. That prevents indefinite "Waiting for agent actions" polling after a dead worker and makes true startup stalls visible in the UI. + - <img width="947" height="398" alt="Image" src="https://github.com/user-attachments/assets/09b45da5-150c-4d3b-82c0-6ba2204c1743" /> + - latest comment by `dosubot[bot]`: <!-- Greeting --> Hi @jidancong! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the MiroFish team. <!-- Answer --> 这个问题通常是因为后端的 agent 动作数据没有正确生成或传递到前端。以下是几个常见原因和排查建议: **1. 检查 LLM API 配置** 最常见的原因是 [API URL 格式不正确](https://github.com… +- #146 [open, mirror=#88] [Feature Request] Add Husky for Git Hook Automated Checks (enhancement) + - local coverage [covered]: The repo now ships an opt-in, repo-native git hook workflow: `.githooks/pre-commit` runs the shared fast validation bundle, `.githooks/pre-push` runs the full validation bundle, and `npm run hooks:install` enables them without introducing a mandatory Husky/Node-only hook dependency. + - Background The current project lacks automated validation before code commits, which may lead to the following issues: 1. Committing non-compliant code (e.g., syntax errors, messy formatting); 2. Inconsistent commit messages, which is not conducive to subsequent maintenance and version tracking; 3. Inefficiency in team collaboration due to the need for manual reminders of specifications. Solution… + +## Recently Updated Pull Requests + +- #144 [open, mergeable=clean, mirrored=yes] feat(kg): add dual-mode knowledge graph support (`feat/local-knowledge-graph` -> `main`) + - local coverage [tracked]: Tracked under beads issue `mirofish-8eg`: the dual-mode local knowledge graph branch is directionally aligned with the non-Zep backend request, but it is not safe to cherry-pick wholesale because it adds a large new adapter plus dependency stack on top of an older tree without current graph/simulation regression coverage. + - ## Summary - Add kg_adapter for dual-mode knowledge graph (cloud/local) - Support switching between Zep Cloud and local Graphiti + Neo4j - Improve entity extraction and report agent robustness - Add test_kg_adapter.py with unit tests ## Test plan - [ ] Test cloud mode with Zep Cloud - [ ] Test local mode with Graphiti + Neo4j - [ ] Run unit tests 🤖 Generated with [Claude Code](https://claude.com/… + - latest comment by `huamingjie0815`: 支持图谱的local 和cloud 双模式,local 是基于graphiti 改造,需要自己配置embedding模型 ,同时该提交增加一些功能优化,包括删除推演记录、导出报告、重新生成报告等功能,调整report_agent 的tool_call 的格式,从json改为xml 。 +- #152 [open, mergeable=clean, mirrored=yes] feat(report): Zep 命名修复与导出 Markdown 功能 (`support-pascal-and-snake-case` -> `main`) + - local coverage [landed]: Landed locally as repo-native subsets instead of a wholesale cherry-pick: ontology generation and graph build submission now normalize type names to Zep-compatible PascalCase / SCREAMING_SNAKE_CASE conventions, and TaskManager state now persists to disk so graph/report task status survives backend restarts. + - ## 概述 本 PR 包含以下改进: 1. **Zep 命名修复**:修复了 Zep API 实体/关系命名的格式校验错误(支持 PascalCase 和 snake_case)。 2. **新增功能**:报告生成步骤支持导出为 Markdown 格式,并采用了正式的 PDF 风格排版。 ## 修改详情 ### 后端 (Backend) - 在 `report_agent.py` 中改进了 `ReportManager.assemble_full_report` 方法,新增了包含 ID、模拟场景和时间戳的正式页眉。 - 添加了章节分隔符,显著提升了导出的 Markdown 文件的可读性。 ### 前端 (Frontend) - 在 `Step4Report.vue` 的报告页眉部分新增了“导出 MD”按钮。 - 在 `src/api/report.js` 中实现了 `downloadRe… +- #155 [open, mergeable=clean, mirrored=yes] chore: backend, frontend, i18n (en/zh), and Docker updates (`english-trans` -> `main`) + - local coverage [tracked]: Tracked under beads issue `mirofish-2ul1`: the combined backend/frontend/i18n/docker sweep is mirrored into `origin/mirror/upstream-pr-155`, but it rewrites 59 files on an older tree and is not safe to cherry-pick wholesale on top of the current repo-native OpenAI-compat, tests, docs, and partial i18n work. + - Made-with: Cursor +- #151 [open, mergeable=clean, mirrored=yes] Fix silent data loss when platform defaults to reddit for Twitter-only simulations (`fix/platform-default-reddit-silent-failure` -> `main`) + - local coverage [landed]: Landed locally before the upstream PR opened: Twitter-only simulations now infer the active platform instead of silently defaulting to Reddit in retrieval APIs and profile loading, matching the intent of upstream PR #151. + - ## Summary - API retrieval endpoints (`/profiles`, `/profiles/realtime`, `/posts`, `/comments`) hardcoded `'reddit'` as the default platform - When a Twitter-only simulation was run (`enable_reddit=false`), these APIs silently returned empty results because they looked for `reddit_simulation.db` / `reddit_profiles.json` which did not exist - Frontend also hardcoded `'reddit'` in Vue components an… +- #147 [open, mergeable=clean, mirrored=yes] feat: Russian localization (Русская локализация) (`russian-localization` -> `main`) + - local coverage [partial]: Safe subset landed locally: added a repo-native `README-RU.md` plus language cross-links, but the full branch is not safe to cherry-pick because it replaces large frontend/backend sections and drops current local tooling, tests, and upstream-triage assets. + - ## 🇷🇺 Russian Localization This PR adds a complete Russian translation of MiroFish: ### Changes: - **15 Vue components** — all UI labels, buttons, placeholders, error messages, and tooltips translated from Chinese to Russian - **README-RU.md** — full Russian documentation with quick start guide - Translation files are in `frontend-ru/src/` (ready to merge into `frontend/src/` when approved) - LLM… +- #141 [open, mergeable=clean, mirrored=yes] feat: add entity deduplication after graph building (`feature/entity-deduplication` -> `main`) + - local coverage [not_safe]: Not safe to cherry-pick: the entity-deduplication branch rewinds large portions of the current tree (tooling, tests, i18n, OpenAI-compatible docs/config) while adding a large graph mutation feature, so it needs a repo-native reimplementation with targeted regression coverage instead of a blind merge. + - Hi @666ghj I noticed that during graph building, Zep sometimes creates duplicate entity nodes for the same real-world entity (e.g. "特朗普" and "美国总统特朗普" appear as separate nodes). This affects the accuracy of the knowledge graph. This PR adds an automatic entity deduplication step after graph building, using name similarity pre-filtering + type compatibility check + LLM confirmation to identify and… +- #143 [open, mergeable=clean, mirrored=yes] docs: fix README alt text URL encoding (`docs/urlEncoding` -> `main`) + - local coverage [landed]: Landed locally as a repo-native docs cleanup: the Shanda logo alt text now uses the correct URL-encoded `666ghj%2FMiroFish` slug across all README variants, not just the primary Chinese README. + - ## Summary Fix the Shanda image alt text in README.md by changing 666ghj%2MiroFish to 666ghj%2FMiroFish. ## Details 666ghj%2MiroFish is not a valid URL-encoded representation, so it cannot be decoded correctly. Using 666ghj%2FMiroFish correctly encodes the slash and can be properly decoded to 666ghj/ MiroFish. ## Impact Documentation-only change. No code or runtime behavior is affected. +- #127 [closed, mergeable=clean, mirrored=yes] Fix potential crash in LLMClient when content is None (`fix/llm-client-none-content` -> `main`) + - local coverage [landed]: Landed locally: `LLMClient.chat()` already coerces `None` completion content to an empty string before post-processing, and `backend/tests/test_llm_client.py` covers the regression explicitly. + - Added `if content is None: return ""` in `backend/app/utils/llm_client.py` to prevent `re.sub` TypeError. --- *Automated PR created by OpenClaw daily-pr routine.* + - latest comment by `sjhddh`: Closing this PR as it was submitted with an incorrect Git author configuration. Apologies for the noise! +- #120 [closed, mergeable=clean, mirrored=yes] fix: 修复subsystems目录下neo4j_client导入路径错误; feat: 添加TODO.md开发规划文档 (`main` -> `main`) + - 新增neo4j 板块 +- #105 [open, mergeable=clean, mirrored=yes] fix: security improvements and error handling fixes (`fix/security-improvements` -> `main`) + - local coverage [landed]: Landed locally: backend security/config hardening now includes env-driven CORS controls with a localhost-only default allowlist, `DEBUG=False` by default, and generated fallback `SECRET_KEY` behavior. + - ## 问题概述 这个PR修复了项目中发现的多个安全问题和代码质量问题。 ## 安全修复 1. **硬编码的SECRET_KEY** - `backend/app/config.py` - 之前:使用硬编码的`'mirofish-secret-key'`作为默认值 - 现在:如果未设置环境变量,会生成随机密钥并发出警告 2. **DEBUG模式默认为True** - `backend/app/config.py` - 之前:`DEBUG`默认为`True` - 现在:`DEBUG`默认为`False`,生产环境更安全 3. **CORS配置允许所有来源** - `backend/app/__init__.py` - 之前:`CORS(app, resources={r"/api/*": {"origins": "*"}})` - 现在:通过环境变量`CORS_ALLOWED_ORIGINS… + - latest comment by `JasonOA888`: ## 代码审查反馈 优秀的PR!这些安全修复非常关键,特别是生产环境部署时。 ### 几个建议: 1. **SECRET_KEY随机生成** - 建议添加日志记录生成的key,方便调试但不要泄露到错误响应中 2. **CORS配置** - 考虑添加`CORS_ALLOW_METHODS`和`CORS_ALLOW_HEADERS`配置,提供更细粒度的控制 3. **error_handler.py** - 建议添加自定义异常类型,让API可以抛出特定错误而不是通用Except… diff --git a/docs/upstream-coverage.json b/docs/upstream-coverage.json new file mode 100644 index 00000000..1a8feb61 --- /dev/null +++ b/docs/upstream-coverage.json @@ -0,0 +1,1172 @@ +{ + "repo": "666ghj/MiroFish", + "captured_at": "2026-03-12T03:21:23Z", + "issues": [ + { + "number": 159, + "status": "tracked", + "summary": "Tracked under beads issue `mirofish-zx6p`: another upstream request for a self-hosted or non-Zep graph backend is preserved locally, but implementing it safely still requires the broader graph-backend abstraction work already tracked under `mirofish-8eg` instead of wiring an ad hoc replacement into the current graph/simulation pipeline.", + "local_refs": [ + ".beads/issues.jsonl", + "docs/upstream-triage.md", + "https://github.com/ivanzud/MiroFish/issues/97" + ], + "validation": [ + "tracking only" + ], + "notes": "Mirrored into fork issue #97 on March 12, 2026. This request overlaps the existing non-Zep backend and self-hosted graph follow-ups from upstream issues #55, #76, #106, and #156." + }, + { + "number": 157, + "status": "covered", + "summary": "Homepage history now supports repo-native deletion of unwanted local records. The backend exposes `DELETE /api/simulation/history/<simulation_id>` to remove a simulation's persisted local directory, cascade-delete its attached local reports, and prune the project metadata when no sibling simulations remain, while refusing deletion for active runs. The history modal now exposes a localized delete action that calls that endpoint directly.", + "local_refs": [ + ".beads/issues.jsonl", + "backend/app/api/simulation.py", + "backend/app/i18n.py", + "backend/app/services/simulation_manager.py", + "backend/tests/test_simulation_api_i18n.py", + "frontend/src/api/simulation.js", + "frontend/src/components/HistoryDatabase.vue", + "frontend/src/i18n/locales/en.js", + "frontend/src/i18n/locales/zh.js", + "https://github.com/ivanzud/MiroFish/issues/96" + ], + "validation": [ + "cd backend && uv run pytest -q tests/test_simulation_api_i18n.py", + "bash ./scripts/test_backend_lite.sh", + "npm --prefix frontend run build" + ], + "notes": "Mirrored into fork issue #96 on March 12, 2026. This is a repo-native local-history cleanup flow; it intentionally avoids deleting upstream or Zep-hosted graph data." + }, + { + "number": 158, + "status": "partial", + "summary": "README.md, README-EN.md, README-RU.md, README-KO.md, and README-JA.md now document a repo-native forecast verification workflow, Step 4 surfaces both the stable `report_id` and `simulation_id` with direct copy actions, the homepage history modal keeps those same references together for later review, and both views now also copy a single structured verification bundle so users can preserve the paired references in one paste. Exported Step 4 Markdown still embeds the report/simulation/graph references directly in the file header, and it now also includes stable local report paths plus a localized manual verification checklist so the saved artifact stays actionable outside the UI. Users can export that Markdown from both Step 4 and the saved-history modal or reuse the files under `backend/uploads/reports/<report_id>/` for later comparison against real-world outcomes. MiroFish still does not ship an automatic ground-truth ingester or scoring pipeline, so full backtesting remains tracked under beads issue `mirofish-gytl`.", + "local_refs": [ + ".beads/issues.jsonl", + "README-EN.md", + "README-JA.md", + "README-KO.md", + "README.md", + "README-RU.md", + "backend/app/services/report_agent.py", + "backend/tests/test_report_agent.py", + "docs/upstream-triage.md", + "frontend/src/components/HistoryDatabase.vue", + "frontend/src/components/historyReportDownload.js", + "frontend/src/components/Step4Report.vue", + "frontend/src/components/verificationBundle.js", + "frontend/src/i18n/locales/en.js", + "frontend/src/i18n/locales/zh.js", + "frontend/tests/historyReportDownload.test.mjs", + "frontend/tests/verificationBundle.test.mjs", + "https://github.com/ivanzud/MiroFish/issues/95" + ], + "validation": [ + "cd backend && uv run pytest -q tests/test_report_agent.py -k \"reference_block or embeds_reference_block\"", + "uv run --project backend pytest -q backend/tests/test_report_agent.py backend/tests/test_report_api_i18n.py", + "python3 -m compileall backend/app/services/report_agent.py backend/tests/test_report_agent.py backend/tests/test_report_api_i18n.py", + "bash ./scripts/test_backend_lite.sh", + "npm --prefix frontend test", + "npm --prefix frontend run build", + "README verification" + ], + "notes": "Mirrored into fork issue #95 on March 12, 2026. The repo now documents how to preserve and revisit forecast evidence across the Chinese, English, Russian, Korean, and Japanese README set, exposes copyable report/simulation IDs in Step 4, surfaces the same references plus a direct Markdown export action in the history modal for later reuse, adds a one-click verification bundle copy in both Step 4 and history so those references can be preserved together, and makes the exported Markdown self-identifying with storage paths plus a manual checklist so the verification evidence survives outside the UI. It still does not automate outcome ingestion or accuracy scoring." + }, + { + "number": 156, + "status": "tracked", + "summary": "Tracked under beads issue `mirofish-gd5z`: upstream wants a simulation/report workflow that avoids Zep graph dependency entirely. The config-status payload and frontend backend diagnostics now expose a capability matrix that separates the direct `OPENAI_*` / Codex-compatible LLM path from Zep-gated Step 1 graph build and graph-backed Step 4 tooling, `/api/report/generate` now fails early with the same structured backend-config payload instead of launching a doomed async Step 4 task, and the frontend now also exposes a simulation-only Step 5 route plus Step 3/Step 4 CTAs so users can continue directly into role interaction without a report when only `ZEP_API_KEY` is missing. Full non-Zep simulation-only execution still needs a dedicated backend-architecture change.", + "local_refs": [ + ".beads/issues.jsonl", + "backend/app/api/report.py", + "backend/app/config.py", + "backend/tests/test_report_api_i18n.py", + "backend/tests/test_config.py", + "backend/tests/test_print_config_status.py", + "frontend/src/components/interactionRoute.js", + "frontend/src/components/apiConfigDiagnostics.js", + "frontend/src/components/Step3Simulation.vue", + "frontend/src/components/Step4Report.vue", + "frontend/src/components/Step5Interaction.vue", + "frontend/src/i18n/locales/en.js", + "frontend/src/i18n/locales/zh.js", + "frontend/tests/apiConfigDiagnostics.test.mjs", + "frontend/tests/interactionRoute.test.mjs", + "frontend/src/router/index.js", + "frontend/src/views/InteractionView.vue", + "README.md", + "README-EN.md", + "https://github.com/ivanzud/MiroFish/issues/94" + ], + "validation": [ + "cd backend && uv run pytest -q tests/test_config.py tests/test_print_config_status.py", + "uv run --project backend pytest -q backend/tests/test_report_api_i18n.py", + "npm --prefix frontend test -- --runInBand apiConfigDiagnostics.test.mjs", + "npm --prefix frontend test", + "npm --prefix frontend run build", + "env -i PATH=\"$PATH\" HOME=\"$HOME\" TERM=\"$TERM\" OPENAI_API_KEY=codex-test-key OPENAI_API_BASE_URL=https://codex.example.test/v1 OPENAI_MODEL=gpt-4.1-mini ZEP_API_KEY=zep-test-key SECRET_KEY=test-secret npm run check:backend-config -- --compact", + "env -i PATH=\"$PATH\" HOME=\"$HOME\" TERM=\"$TERM\" OPENAI_API_KEY=codex-test-key OPENAI_API_BASE_URL=https://codex.example.test/v1 OPENAI_MODEL=gpt-4.1-mini SECRET_KEY=test-secret npm run check:backend-config -- --compact", + "bash ./scripts/test_backend_lite.sh" + ], + "notes": "Mirrored into fork issue #94 on March 12, 2026. The landed capability matrix makes it explicit that direct OpenAI-compatible LLM wiring can still be valid even when Step 1 / Step 4 remain blocked on Zep, and that Step 5 interaction is still available on an existing simulation environment. This branch now also returns that same non-sensitive config payload directly from `/api/report/generate`, so Step 4 no longer starts a doomed async task when only the direct LLM path is configured, and the frontend now gives users a repo-native escape hatch into `/interaction/simulation/<simulation_id>` from both Step 3 preflight failures and Step 4 failed-report screens." + }, + { + "number": 154, + "status": "covered", + "summary": "Profile serialization now tolerates structured LLM output instead of crashing when `bio`, `persona`, `country`, `profession`, or `interested_topics` arrive as dict/list values. `OasisAgentProfile` normalizes those mixed types at construction time and the Reddit/Twitter serializers defensively coerce them again before slicing or string replacement, so simulation preparation no longer fails during profile save with `KeyError: slice(None, 150, None)`.", + "local_refs": [ + ".beads/issues.jsonl", + "backend/app/services/oasis_profile_generator.py", + "backend/tests/test_openai_compat_services.py", + "https://github.com/ivanzud/MiroFish/issues/93" + ], + "validation": [ + "cd backend && uv run pytest -q tests/test_openai_compat_services.py -k \"structured_fields or save_profiles_defaults_country_by_locale or save_twitter_profiles_tolerates_structured_fields\"", + "bash ./scripts/test_backend_lite.sh" + ], + "notes": "Mirrored into fork issue #93 on March 12, 2026. This is a repo-native fix for the newly reported upstream serialization crash rather than a PR cherry-pick." + }, + { + "number": 150, + "status": "covered", + "summary": "Simulation data retrieval now resolves the active platform from `SimulationState` instead of silently defaulting to Reddit when Twitter/X is the only enabled platform, and `SimulationManager.get_profiles()` now reads Twitter profile CSV files correctly as well. That prevents empty profile/post responses in Twitter-only simulations even when older callers still pass the historical `reddit` default.", + "local_refs": [ + ".beads/issues.jsonl", + "backend/app/services/simulation_manager.py", + "backend/app/api/simulation.py", + "backend/tests/test_simulation_service_i18n.py", + "backend/tests/test_simulation_api_i18n.py", + "https://github.com/ivanzud/MiroFish/issues/91" + ], + "validation": [ + "cd backend && uv run pytest -q tests/test_simulation_service_i18n.py tests/test_simulation_api_i18n.py", + "python3 -m compileall backend/app/services/simulation_manager.py backend/app/api/simulation.py" + ], + "notes": "Mirrored into fork issue #91 on March 11, 2026. The fix keeps legacy callers working by falling back to the sole enabled platform instead of assuming Reddit." + }, + { + "number": 153, + "status": "covered", + "summary": "The current branch no longer reproduces a `pillow` build during the default `npm run setup:all` core install path. `setup:backend` now maps to a plain `uv sync` of the core graph/report/OpenAI-compatible backend dependencies, while the heavyweight simulation runtime remains behind the separate `setup:backend:simulation` entrypoint. A Windows + Python 3.13 dry-run of `uv sync --frozen` against the current lockfile does not attempt to install `pillow` at all, and the optional simulation lock now resolves `pillow==10.4.0` instead of `10.3.0`.", + "local_refs": [ + "package.json", + "README.md", + "README-EN.md", + "scripts/setup_backend_simulation.py", + "backend/uv.lock" + ], + "validation": [ + "cd backend && uv sync --frozen --python 3.13 --python-platform windows --dry-run --output-format json", + "cd backend && uv sync --extra simulation --frozen --dry-run", + "triage diff review" + ], + "notes": "Mirrored into fork issue #92 on March 11, 2026. If the reporter still sees `pillow==10.3.0` during `setup:all`, they are likely on an older checkout. The current core path avoids Pillow entirely and the optional simulation path now locks Pillow to `10.4.0`." + }, + { + "number": 148, + "status": "covered", + "summary": "Interview env liveness now validates the persisted runner state and recorded process PID instead of trusting stale env_status.json alone, so Step 5 world-agent chat fails fast with the existing closed-environment guidance instead of hanging into a 504 when the simulation process has already exited.", + "local_refs": [ + "backend/app/services/simulation_runner.py", + "backend/tests/test_simulation_runner_actions.py", + "backend/tests/test_simulation_api_i18n.py" + ] + }, + { + "number": 149, + "status": "covered", + "summary": "Step 3 now reconciles stale persisted `running` states when the worker PID is gone, and the detailed status payload exposes compact simulation-log diagnostics while waiting for the first actions. That prevents indefinite \"Waiting for agent actions\" polling after a dead worker and makes true startup stalls visible in the UI.", + "local_refs": [ + "backend/app/services/simulation_runner.py", + "backend/app/api/simulation.py", + "backend/tests/test_simulation_runner_actions.py", + "backend/tests/test_simulation_run_status_detail.py", + "frontend/src/components/Step3Simulation.vue" + ], + "validation": [ + "cd backend && uv run pytest -q tests/test_simulation_runner_actions.py tests/test_simulation_run_status_detail.py", + "npm --prefix frontend test", + "npm --prefix frontend run build" + ] + }, + { + "number": 17, + "status": "tracked", + "summary": "Tracked locally in beads as `mirofish-77h`: a future multi-run consensus workflow should reuse a shared Step 1/2 setup, orchestrate repeated Step 3 runs, and synthesize a consensus/divergence report in Step 4.", + "local_refs": [ + ".beads/issues.jsonl", + "docs/upstream-triage.md" + ], + "validation": [ + "tracking only" + ] + }, + { + "number": 14, + "status": "covered", + "summary": "Step 3 now stops polling and surfaces backend failure text when the simulation runner reports `failed`, so the UI no longer hangs indefinitely.", + "local_refs": [ + "frontend/src/components/Step3Simulation.vue", + "frontend/tests/errors.test.mjs" + ], + "validation": [ + "frontend: npm test", + "frontend: npm run build" + ] + }, + { + "number": 19, + "status": "covered", + "summary": "The home upload flow and quick-start docs now explicitly recommend starting with smaller source material (roughly 10k words) and about 30 simulation rounds on a first run, reducing avoidable quota burn and onboarding confusion.", + "local_refs": [ + "frontend/src/views/Home.vue", + "frontend/src/i18n/locales/zh.js", + "frontend/src/i18n/locales/en.js", + "README.md", + "README-EN.md" + ], + "validation": [ + "frontend: npm run build" + ] + }, + { + "number": 24, + "status": "covered", + "summary": "Report generation now tolerates empty LLM section responses by retrying, then falling back to a per-section placeholder instead of crashing the whole report flow with a `NoneType` subscript error.", + "local_refs": [ + "backend/app/services/report_agent.py", + "backend/tests/test_report_agent.py" + ], + "validation": [ + "backend/tests/test_report_agent.py::test_generate_report_survives_empty_llm_section_responses" + ] + }, + { + "number": 45, + "status": "covered", + "summary": "Step 5 now preserves platform metadata and targets Reddit/Twitter interviews against the matching backend instead of assuming a Reddit-only profile list.", + "local_refs": [ + "frontend/src/components/Step5Interaction.vue", + "frontend/src/components/step5Profiles.js", + "frontend/tests/step5Profiles.test.mjs" + ], + "validation": [ + "frontend/tests/step5Profiles.test.mjs", + "frontend: npm run build" + ] + }, + { + "number": 46, + "status": "covered", + "summary": "The setuptools `project.license` warning path is gone because the repo now uses an SPDX license string.", + "local_refs": [ + "backend/pyproject.toml" + ], + "validation": [ + "backend: uv run pytest -q" + ] + }, + { + "number": 52, + "status": "covered", + "summary": "Report generation now trims oversized message history, retries after context-length failures, and exposes configurable `LLM_MAX_TOKENS` for smaller-context models.", + "local_refs": [ + "backend/app/services/report_agent.py", + "backend/app/utils/llm_client.py", + "backend/tests/test_report_agent.py", + "backend/tests/test_llm_client.py" + ], + "validation": [ + "backend/tests/test_report_agent.py", + "backend/tests/test_llm_client.py" + ] + }, + { + "number": 58, + "status": "covered", + "summary": "Step 5 interviews now derive timeout overrides from the configured frontend request window, and the backend/docs expose dedicated interview timeout settings for slower local models.", + "local_refs": [ + "frontend/src/api/timeout.js", + "frontend/src/components/Step5Interaction.vue", + ".env.example" + ], + "validation": [ + "frontend: npm test", + "frontend: npm run build" + ] + }, + { + "number": 61, + "status": "covered", + "summary": "The frontend now exposes a persisted runtime backend API override panel on the home screen and Step 1 / Step 2 workbench, so users can repoint the UI to another backend without rebuilding.", + "local_refs": [ + "frontend/src/api/baseUrl.js", + "frontend/src/api/index.js", + "frontend/src/components/ApiEndpointControl.vue", + "frontend/src/views/Home.vue", + "frontend/src/views/MainView.vue", + "README.md", + "README-EN.md" + ], + "validation": [ + "frontend/tests/baseUrl.test.mjs", + "frontend: npm run build" + ] + }, + { + "number": 62, + "status": "partial", + "summary": "The Step 5 timeout complaint now has explicit local mitigations across both UI-driven and report-agent-driven interviews: frontend Step 5 requests derive adaptive timeout budgets from the request window, the UI shows the effective single-agent and current survey-batch budget, the docs/env template expose timeout knobs for slower local models, and backend/app/services/zep_tools.py now honors INTERVIEW_BATCH_TIMEOUT_SECONDS instead of forcing a fixed 180-second timeout for live report-agent batch interviews. The broader issue's remaining workflow requests are now tracked separately in beads as `mirofish-as6`.", + "local_refs": [ + ".beads/issues.jsonl", + "frontend/src/api/timeout.js", + "frontend/src/components/Step5Interaction.vue", + "frontend/src/components/step5Profiles.js", + "backend/app/services/zep_tools.py", + "backend/tests/test_zep_tools_i18n.py", + ".env.example", + "README.md", + "README-EN.md" + ], + "validation": [ + "frontend: npm test", + "frontend: npm run build", + "backend: uv run pytest -q tests/test_zep_tools_i18n.py" + ] + }, + { + "number": 64, + "status": "covered", + "summary": "Upload and graph-build failures now surface structured per-file parser/config validation errors instead of collapsing common deployment or document-ingest problems into a generic 500.", + "local_refs": [ + "backend/app/api/graph.py", + "backend/app/config.py", + "frontend/src/views/Process.vue", + "backend/tests/test_graph_upload_api.py" + ], + "validation": [ + "scripts/test_backend_lite.sh", + "frontend: npm test", + "frontend: npm run build" + ] + }, + { + "number": 77, + "status": "covered", + "summary": "Graph-build task failures now classify Zep 401/unauthorized responses into a concise ZEP_API_KEY guidance message and strip embedded traceback noise before returning task payload errors, so deployments behind domestic proxies or with invalid Zep credentials no longer fail with an opaque auth dump.", + "local_refs": [ + "backend/app/services/graph_builder.py", + "backend/tests/test_graph_builder.py", + "backend/tests/test_graph_upload_api.py", + "https://github.com/ivanzud/MiroFish/issues/54" + ], + "validation": [ + "cd backend && uv run pytest -q tests/test_graph_builder.py tests/test_graph_upload_api.py -k \"auth_error or unauthorized\"" + ], + "notes": "Mirrored into fork issue #54 on March 11, 2026. This backfills the same repo-native Zep auth hardening already captured for upstream issue #139." + }, + { + "number": 68, + "status": "covered", + "summary": "Simulation process-exit errors now classify common HuggingFace download/proxy failures into a concise retry/proxy guidance message instead of dumping raw log tails into Step 3.", + "local_refs": [ + "backend/app/services/simulation_runner.py", + "backend/app/i18n.py", + "backend/tests/test_simulation_runner_actions.py" + ], + "validation": [ + "backend/tests/test_simulation_runner_actions.py", + "backend/tests/test_simulation_api_i18n.py", + "backend/tests/test_i18n.py" + ] + }, + { + "number": 84, + "status": "covered", + "summary": "Failed report generation can now be retried directly from Step 4 with persisted status polling, backend error text, and a `force_regenerate` retry path.", + "local_refs": [ + "frontend/src/components/Step4Report.vue", + "frontend/src/api/report.js" + ], + "validation": [ + "frontend: npm run build" + ] + }, + { + "number": 93, + "status": "covered", + "summary": "The frontend no longer hardcodes `http://localhost:5001` in project-init messaging and instead uses the shared API base-url resolver.", + "local_refs": [ + "frontend/src/views/Process.vue", + "frontend/src/api/baseUrl.js" + ], + "validation": [ + "frontend/tests/baseUrl.test.mjs", + "frontend: npm run build" + ] + }, + { + "number": 92, + "status": "covered", + "summary": "The current GitHub Actions workflow already includes the later upgrade sweep from upstream PR #116, so this issue is stale on this branch.", + "local_refs": [ + ".github/workflows/docker-image.yml" + ], + "validation": [ + "workflow review only" + ] + }, + { + "number": 99, + "status": "covered", + "summary": "Docker image publishing now builds both `linux/amd64` and `linux/arm64`, so the missing ARM image issue is already resolved locally.", + "local_refs": [ + ".github/workflows/docker-image.yml" + ], + "validation": [ + "workflow review only" + ] + }, + { + "number": 9, + "status": "partial", + "summary": "Refresh/navigation is safer locally because Step 3 reattaches to existing simulation state instead of force-restarting, the history modal exposes a replay-only Step 3 route that loads an existing timeline without accidentally auto-starting a fresh run, the Step 3 panel now makes the same-simulation restart path explicit after quota/API-key failures, Step 2 now surfaces a direct recovery card that reopens the saved Step 3 replay/restart route without making users hunt through history first, and Step 5 now exposes that same direct Step 3 recovery route whenever the interview environment is offline but the prepared simulation still has replayable state. True mid-run checkpoint/resume is still deferred.", + "local_refs": [ + "frontend/src/components/Step2EnvSetup.vue", + "frontend/src/components/Step5Interaction.vue", + "frontend/src/components/step2Recovery.js", + "frontend/src/components/step5Recovery.js", + "frontend/src/components/HistoryDatabase.vue", + "frontend/src/views/SimulationRunView.vue", + "frontend/src/views/SimulationView.vue", + "frontend/src/components/Step3Simulation.vue", + "frontend/src/components/historyPlayback.js", + "frontend/src/components/simulationReplay.js", + "frontend/tests/historyPlayback.test.mjs", + "frontend/tests/step2Recovery.test.mjs", + "frontend/tests/step5Recovery.test.mjs" + ], + "validation": [ + "frontend: npm test", + "frontend: npm run build" + ], + "notes": "The repo still cannot checkpoint and continue from the exact failure point after a provider outage, but the prepared-environment recovery path is now available from history, the Step 2 screen, and the Step 5 offline-interview workspace." + }, + { + "number": 21, + "status": "covered", + "summary": "The docs/UI now clarify that refreshing the browser does not stop backend jobs, persisted runs remain reopenable from history, and Step 3/5 still require a live runtime session.", + "local_refs": [ + "frontend/src/components/HistoryDatabase.vue", + "README.md", + "README-EN.md" + ], + "validation": [ + "frontend: npm run build" + ] + }, + { + "number": 37, + "status": "covered", + "summary": "Step 5 now preflights simulation environment status and blocks interview requests against closed or unavailable backends before they fail.", + "local_refs": [ + "frontend/src/components/Step5Interaction.vue", + "frontend/src/api/simulation.js" + ], + "validation": [ + "frontend: npm test", + "frontend: npm run build" + ] + }, + { + "number": 43, + "status": "covered", + "summary": "Step 5 now rewrites timeout and environment-closed failures into actionable guidance, and the backend/frontend timeout knobs give slow local models an explicit supported path.", + "local_refs": [ + "frontend/src/components/Step5Interaction.vue", + "frontend/src/api/timeout.js", + ".env.example" + ], + "validation": [ + "frontend: npm test", + "frontend: npm run build" + ] + }, + { + "number": 42, + "status": "covered", + "summary": "Step 3 detailed run polling is now incremental and bounded, so the frontend no longer re-downloads the entire simulation timeline every few seconds as a run grows.", + "local_refs": [ + "backend/app/api/simulation.py", + "frontend/src/components/Step3Simulation.vue", + "frontend/src/components/simulationTimeline.js" + ], + "validation": [ + "scripts/test_backend_lite.sh", + "frontend: npm test", + "frontend: npm run build" + ] + }, + { + "number": 60, + "status": "covered", + "summary": "Graph-builder retries now honor bounded Zep `Retry-After` guidance from `429` responses instead of always using the default backoff schedule, reducing premature failures when Zep throttles graph creation or uploads.", + "local_refs": [ + "backend/app/config.py", + "backend/app/services/graph_builder.py", + "backend/tests/test_graph_builder.py" + ], + "validation": [ + "backend/tests/test_graph_builder.py::test_create_graph_respects_retry_after_header", + "backend/tests/test_graph_builder.py::test_create_graph_respects_retry_after_text_hint", + "backend/tests/test_graph_builder.py::test_create_graph_caps_retry_after_delay", + "scripts/test_backend_lite.sh" + ], + "notes": "Mirrored into fork issue tracking on March 11, 2026 after enabling issues on ivanzud/MiroFish." + }, + { + "number": 75, + "status": "covered", + "summary": "Graph-builder retries now honor bounded Zep `Retry-After` guidance from `429` responses instead of always using the default backoff schedule, reducing the stuck-at-99% failure mode when Zep free-plan throttling delays graph processing.", + "local_refs": [ + "backend/app/config.py", + "backend/app/services/graph_builder.py", + "backend/tests/test_graph_builder.py" + ], + "validation": [ + "backend/tests/test_graph_builder.py::test_create_graph_respects_retry_after_header", + "backend/tests/test_graph_builder.py::test_create_graph_respects_retry_after_text_hint", + "backend/tests/test_graph_builder.py::test_create_graph_caps_retry_after_delay", + "scripts/test_backend_lite.sh" + ], + "notes": "Mirrored into fork issue tracking on March 11, 2026 after enabling issues on ivanzud/MiroFish." + }, + { + "number": 107, + "status": "covered", + "summary": "Docker deployment now reads `MIROFISH_IMAGE` from `.env` or the shell, so GHCR pull failures can be worked around with a mirror/private registry override instead of editing `docker-compose.yml`.", + "local_refs": [ + "docker-compose.yml", + ".env.example", + "README.md", + "README-EN.md", + "README-JA.md", + "README-KO.md" + ], + "validation": [ + "docker compose config" + ], + "notes": "Mirrored into fork issue tracking on March 11, 2026 after enabling issues on ivanzud/MiroFish." + }, + { + "number": 54, + "status": "no_action", + "summary": "Upstream issue #54 is positive feedback rather than a reproducible bug or scoped feature request, so there is no local implementation task attached to it.", + "local_refs": [ + "docs/upstream-open-state.json" + ], + "validation": [ + "triage only" + ] + }, + { + "number": 55, + "status": "tracked", + "summary": "Tracked under beads issue `mirofish-8eg`: local graph backend alternatives such as Neo4j support need a deliberate backend-abstraction design pass instead of a blind merge of third-party code.", + "local_refs": [ + ".beads/issues.jsonl", + "docs/upstream-triage.md" + ], + "validation": [ + "tracking only" + ] + }, + { + "number": 56, + "status": "reference", + "summary": "Upstream issue #56 is a community discussion thread collecting localized Zep alternatives. It is retained as reference material while any concrete backend-abstraction work stays tracked under `mirofish-8eg`.", + "local_refs": [ + ".beads/issues.jsonl", + "docs/upstream-open-state.json" + ], + "validation": [ + "triage only" + ] + }, + { + "number": 76, + "status": "tracked", + "summary": "Tracked under beads issue `mirofish-8eg`: RAGflow support remains a backend-abstraction/rebase task and is not safe to land via the stale upstream branch without targeted regression coverage.", + "local_refs": [ + ".beads/issues.jsonl", + "docs/upstream-triage.md" + ], + "validation": [ + "tracking only" + ] + }, + { + "number": 106, + "status": "tracked", + "summary": "Tracked under beads issue `mirofish-8eg`: requests for non-Zep graph backends are preserved locally, but implementing them safely requires a fresh graph-backend abstraction instead of wiring another provider into current graph/simulation flows ad hoc.", + "local_refs": [ + ".beads/issues.jsonl", + "docs/upstream-triage.md" + ], + "validation": [ + "tracking only" + ] + }, + { + "number": 109, + "status": "covered", + "summary": "The home upload flow and quick-start docs now explicitly recommend starting with smaller source material (roughly 10k words) and about 30 simulation rounds on a first run, giving new local users a lower-cost way to verify the graph and runtime chain before they burn through Zep free-plan quota.", + "local_refs": [ + "frontend/src/views/Home.vue", + "frontend/src/i18n/locales/zh.js", + "frontend/src/i18n/locales/en.js", + "README.md", + "README-EN.md", + "https://github.com/ivanzud/MiroFish/issues/43" + ], + "validation": [ + "npm --prefix frontend run build", + "rg -n \"10k words|30 rounds|1 万字以内|30 轮左右\" frontend/src/views/Home.vue frontend/src/i18n/locales/zh.js frontend/src/i18n/locales/en.js README.md README-EN.md" + ], + "notes": "Mirrored into fork issue #43 on March 11, 2026. This backfills the same first-run quota guidance already tracked under upstream issue #19." + }, + { + "number": 110, + "status": "covered", + "summary": "The backend and docs now support direct OpenAI-compatible gateways plus OPENAI_* aliases, including a documented DashScope Coding Plan example, so users no longer need a provider-specific raw LLM setup path.", + "local_refs": [ + "README.md", + "README-EN.md", + ".env.example", + "backend/app/config.py", + "backend/app/api/graph.py", + "backend/tests/test_openai_compat_services.py" + ], + "validation": [ + "backend/tests/test_openai_compat_services.py::test_simulation_config_generator_missing_api_key_mentions_openai_alias", + "backend/tests/test_openai_compat_services.py::test_simulation_config_generator_missing_api_key_english_message" + ], + "notes": "Mirrored into fork issue tracking on March 11, 2026 after enabling issues on ivanzud/MiroFish." + }, + { + "number": 117, + "status": "covered", + "summary": "The English support sweep now covers workflow chrome, deterministic Step 2/3 system-log copy, graph-build worker progress, report/interview parsing, Step 5 interview fallbacks, and simulation-config placeholder labels: Step 3/5 labels flow through shared i18n dictionaries, Step 2 prepare-stage progress and Step 3 round/PID logs localize through shared helpers, GraphBuilderService now persists English task-status strings for its initial worker milestones, Step 4 tool-output parsers accept both Chinese and English markers, zep_tools localizes deterministic interview-selection/question/summary fallback copy in English mode, and SimulationConfigGenerator now routes unknown entity/poster fallback labels through the active locale instead of leaking hardcoded English `Unknown` into zh-mode config output.", + "local_refs": [ + "frontend/src/components/Step2EnvSetup.vue", + "frontend/src/components/Step3Simulation.vue", + "backend/app/services/graph_builder.py", + "frontend/src/components/Step4Report.vue", + "frontend/src/components/Step5Interaction.vue", + "frontend/src/components/reportParsers.js", + "frontend/src/components/simulationLogMessages.js", + "frontend/src/components/step5Profiles.js", + "frontend/src/components/simulationTimeline.js", + "backend/app/services/zep_tools.py", + "backend/app/services/simulation_config_generator.py", + "frontend/src/i18n/locales/en.js", + "frontend/src/i18n/locales/zh.js", + "backend/tests/test_graph_builder.py", + "backend/tests/test_openai_compat_services.py", + "frontend/tests/simulationLogMessages.test.mjs", + "backend/tests/test_zep_tools_i18n.py" + ], + "validation": [ + "backend/tests/test_graph_builder.py", + "backend/tests/test_openai_compat_services.py", + "frontend/tests/reportParsers.test.mjs", + "frontend/tests/simulationLogMessages.test.mjs", + "frontend/tests/simulationTimeline.test.mjs", + "frontend/tests/step5Profiles.test.mjs", + "backend/tests/test_zep_tools_i18n.py", + "frontend: npm test", + "frontend: npm run build", + "scripts/test_backend_lite.sh" + ], + "notes": "Mirrored into fork issue tracking on March 11, 2026 after enabling issues on ivanzud/MiroFish." + }, + { + "number": 121, + "status": "covered", + "summary": "New-project startup errors now resolve raw frontend `Network Error` failures into an actionable backend URL and proxy/CORS hint instead of leaving users at a generic `handleNewProject` failure banner.", + "local_refs": [ + "frontend/src/api/errors.js", + "frontend/tests/errors.test.mjs", + "https://github.com/ivanzud/MiroFish/issues/41" + ], + "validation": [ + "npm --prefix frontend test -- errors.test.mjs", + "npm --prefix frontend run build" + ], + "notes": "Mirrored into fork issue #41 on March 11, 2026. This backfills the landed frontend error-diagnostics work already represented by upstream PR #125." + }, + { + "number": 133, + "status": "covered", + "summary": "The backend root path `/` plus `/health` and `/healthz` now return a small JSON status payload with the live API prefixes, so local or Docker users no longer need to infer backend health from a bare 404.", + "local_refs": [ + "backend/app/__init__.py", + "backend/tests/test_app_routes.py" + ], + "validation": [ + "backend/tests/test_app_routes.py::test_root_and_health_endpoints_expose_backend_status" + ], + "notes": "Mirrored into fork issue tracking on March 11, 2026 after enabling issues on ivanzud/MiroFish." + }, + { + "number": 135, + "status": "covered", + "summary": "Graph ontology ingestion now accepts string-valued attribute definitions for entity and edge schemas, so malformed LLM ontology output no longer crashes graph builds with `TypeError: string indices must be integers`.", + "local_refs": [ + "backend/app/services/graph_builder.py", + "backend/tests/test_graph_builder.py" + ], + "validation": [ + "backend/tests/test_graph_builder.py::test_set_ontology_accepts_string_attribute_definitions" + ], + "notes": "Mirrored into fork issue tracking on March 11, 2026 after enabling issues on ivanzud/MiroFish." + }, + { + "number": 139, + "status": "covered", + "summary": "Graph-build task failures now classify Zep 401/unauthorized responses into a concise ZEP_API_KEY guidance message and strip embedded traceback noise before returning task payload errors.", + "local_refs": [ + "backend/app/services/graph_builder.py", + "backend/tests/test_graph_builder.py", + "backend/tests/test_graph_upload_api.py" + ], + "validation": [ + "backend/tests/test_graph_builder.py::test_format_user_facing_error_maps_embedded_traceback_auth_failures", + "backend/tests/test_graph_upload_api.py::test_build_graph_task_persists_sanitized_zep_auth_error" + ], + "notes": "Mirrored into fork issue tracking on March 11, 2026 after enabling issues on ivanzud/MiroFish." + }, + { + "number": 140, + "status": "no_action", + "summary": "Upstream issue #140 is general project commentary rather than an actionable defect report or scoped feature request, so it does not require local implementation work.", + "local_refs": [ + "docs/upstream-open-state.json" + ], + "validation": [ + "triage only" + ] + }, + { + "number": 142, + "status": "no_action", + "summary": "Upstream issue #142 asks about long-term commercialization direction rather than reporting a reproducible bug or scoped feature request, so there is no local implementation task attached to it.", + "local_refs": [ + "docs/upstream-open-state.json" + ], + "validation": [ + "triage only" + ] + }, + { + "number": 145, + "status": "partial", + "summary": "Repo-native partial mitigations are now landed locally across simulation inputs, backend graph/report/search/statistics/detail surfaces, raw graph introspection, node-edge introspection, textual tool output, both shipped graph renderers, and the visible frontend graph counters/logs: `ZepEntityReader.filter_defined_entities()` collapses obvious same-entity alias variants before simulation/profile generation, `ZepEntityReader.get_entity_with_context()` now merges alias-linked relations and related nodes for the entity-detail API, `backend/app/services/graph_builder.py` now collapses the same conservative alias pairs when serving `/api/graph/data/<graph_id>` and remaps duplicate edges to the retained node UUID, `backend/app/services/zep_tools.py` now collapses those aliases when building typed entity lists, raw node/edge introspection payloads, Panorama output, InsightForge entity/relationship summaries, QuickSearch/general search results, graph statistics, node-edge lookups, entity summaries including relations attached only to alias UUIDs, and `NodeInfo.to_text()` output, while preserving merged `alias_names` metadata so callers and downstream prompts can still see which labels were folded together. `frontend/src/views/processGraphData.js` and the shared `frontend/src/components/GraphPanel.vue` renderer now both collapse them while rendering graph data, the Process plus GraphPanel node detail drawers expose the folded non-canonical aliases via `frontend/src/components/graphAliasDetails.js`, and `frontend/src/components/graphPanelData.js` now drives deduplicated Step 1 / Process counters plus MainView refresh logs so title-prefixed duplicates such as `美国总统特朗普` vs `特朗普` no longer appear twice in the graph or its visible counts. Full graph-level persisted deduplication still remains tracked under beads issue `mirofish-975` because upstream PR #141 is not safe to cherry-pick wholesale.", + "local_refs": [ + ".beads/issues.jsonl", + "docs/upstream-triage.md", + "origin/mirror/upstream-pr-141", + "backend/app/services/zep_entity_reader.py", + "backend/tests/test_zep_entity_reader.py", + "backend/app/services/graph_builder.py", + "backend/tests/test_graph_builder.py", + "backend/app/services/zep_tools.py", + "backend/tests/test_zep_tools_dedup.py", + "backend/tests/test_zep_tools_i18n.py", + "frontend/src/components/GraphPanel.vue", + "frontend/src/components/graphAliasDetails.js", + "frontend/src/components/graphPanelData.js", + "frontend/src/components/Step1GraphBuild.vue", + "frontend/tests/graphAliasDetails.test.mjs", + "frontend/tests/graphPanelData.test.mjs", + "frontend/src/views/MainView.vue", + "frontend/src/views/Process.vue", + "frontend/src/views/processGraphData.js", + "frontend/tests/processGraphData.test.mjs" + ], + "validation": [ + "cd backend && uv run pytest -q tests/test_graph_builder.py tests/test_zep_entity_reader.py tests/test_zep_tools_dedup.py tests/test_zep_tools_i18n.py", + "python3 -m compileall backend/app/services/zep_entity_reader.py backend/app/services/graph_builder.py backend/app/services/zep_tools.py", + "bash ./scripts/test_backend_lite.sh", + "frontend: npm --prefix frontend test", + "frontend: npm --prefix frontend run build", + "triage diff review" + ], + "notes": "Mirrored into fork issue tracking on March 11, 2026 after enabling issues on ivanzud/MiroFish. 2026-03-11: Added a third repo-native partial mitigation in backend report/search tooling so conservative alias pairs are collapsed before Step 4/analysis surfaces render entity summaries and relationship chains. 2026-03-11: Added a fourth repo-native partial mitigation in backend graph-data responses so `/api/graph/data/<graph_id>` collapses the same obvious alias pairs and remaps duplicate edges without mutating stored Zep nodes. 2026-03-11: Added a fifth repo-native partial mitigation in QuickSearch/general search results so `search_graph()` and the local-search fallback collapse the same obvious alias pairs, deduplicate summary facts, and remap duplicate edges before report tooling or API callers consume the result payload. 2026-03-11: Added a seventh repo-native partial mitigation in backend graph statistics and entity-summary helpers so alias duplicates no longer inflate stats counts and alias queries resolve to the canonical merged entity payload. 2026-03-11: Added an eighth repo-native partial mitigation in backend entity summaries so alias-linked relations are collected from the full edge set before canonical remapping, which prevents summaries from dropping edges attached only to an alias UUID. 2026-03-11: Added a ninth repo-native partial mitigation in backend entity-detail reads so `get_entity_with_context()` resolves the requested node against its alias group, includes alias-linked relations, and deduplicates related-node payloads. 2026-03-11: Added a tenth repo-native partial mitigation in zep_tools node-edge lookups so `get_node_edges()` now resolves the requested node against its alias group and remaps duplicate edges instead of dropping relationships attached only to an alias UUID. 2026-03-11: Added an eleventh repo-native partial mitigation in raw zep_tools graph introspection so `get_all_nodes()` and `get_all_edges()` now collapse obvious alias duplicates and remap duplicate edge payloads before report-side callers consume the graph snapshot. 2026-03-11: Added a fourteenth repo-native partial mitigation in textual zep_tools node rendering so `NodeInfo.to_text()` now includes merged alias names with locale-aware labels, preserving the folded names in downstream report/runtime prompts. 2026-03-11: Added a fifteenth repo-native partial mitigation in the shared frontend graph renderer so `frontend/src/components/GraphPanel.vue` now reuses the conservative display-only alias-collapse mapper and no longer shows obvious duplicate entities outside the Process view. 2026-03-11: Added a sixteenth repo-native partial mitigation in the Process and shared GraphPanel node detail drawers so `frontend/src/components/graphAliasDetails.js` filters merged `alias_names` down to the folded non-canonical labels and both detail panels render them explicitly instead of hiding which source names collapsed into the canonical node. 2026-03-11: Added a seventeenth repo-native partial mitigation in frontend graph stats so `frontend/src/components/graphPanelData.js` now drives Step 1 / Process counters and MainView refresh logs through the same alias-collapse mapping as the visible graph renderer." + }, + { + "number": 146, + "status": "covered", + "summary": "The repo now ships an opt-in, repo-native git hook workflow: `.githooks/pre-commit` runs the shared fast validation bundle, `.githooks/pre-push` runs the full validation bundle, and `npm run hooks:install` enables them without introducing a mandatory Husky/Node-only hook dependency.", + "local_refs": [ + ".githooks/pre-commit", + ".githooks/pre-push", + "scripts/validate_repo.sh", + "scripts/install_git_hooks.sh", + "CONTRIBUTING.md", + "package.json", + "https://github.com/ivanzud/MiroFish/issues/88" + ], + "validation": [ + "bash ./scripts/validate_repo.sh --backend-only", + "bash ./scripts/validate_repo.sh --frontend-only", + "bash ./scripts/install_git_hooks.sh --help" + ], + "notes": "Mirrored into fork issue #88 on March 11, 2026. The workflow is intentionally opt-in and uses git core.hooksPath instead of requiring Husky." + }, + { + "number": 69, + "status": "covered", + "summary": "Both READMEs now explicitly document direct OpenAI-compatible usage through either `LLM_*` or `OPENAI_*` environment variables, with examples for Codex/OpenAI-compatible gateways, DashScope, LM Studio, and Ollama.", + "local_refs": [ + "README.md", + "README-EN.md", + ".env.example" + ], + "validation": [ + "documentation review" + ] + } + ], + "pull_requests": [ + { + "number": 15, + "status": "landed", + "summary": "Landed locally: Step 3 now surfaces failed simulation status instead of polling forever.", + "local_refs": [ + "frontend/src/components/Step3Simulation.vue" + ], + "validation": [ + "frontend: npm run build" + ] + }, + { + "number": 38, + "status": "not_safe", + "summary": "Not safe to cherry-pick: it expands the backend API surface to Anthropic-specific protocol work while this branch is intentionally standardizing on OpenAI-compatible gateways." + }, + { + "number": 49, + "status": "not_safe", + "summary": "Not safe to cherry-pick: it introduces a large local graph backend without targeted regression coverage and is superseded conceptually by the broader backend-abstraction follow-up." + }, + { + "number": 70, + "status": "not_safe", + "summary": "Not safe to cherry-pick: the Windows installer flow assumes a repo topology that does not match the current `run.py` plus built-frontend layout." + }, + { + "number": 72, + "status": "superseded", + "summary": "Superseded locally by the broader `_extract_json_payload()` handling in `backend/app/utils/llm_client.py`." + }, + { + "number": 73, + "status": "landed", + "summary": "Landed locally: malformed ontology entity and edge items are sanitized before fallback injection." + }, + { + "number": 74, + "status": "landed", + "summary": "Landed locally: bare `except:` clauses in the reviewed JSON/simulation paths were narrowed to `except Exception:`." + }, + { + "number": 81, + "status": "landed", + "summary": "Landed locally: frontend API timeout is configurable for slow local and OpenAI-compatible backends." + }, + { + "number": 82, + "status": "covered", + "summary": "The underlying dependency-risk issue is addressed locally without cherry-picking: simulation/runtime packages moved behind an explicit optional install path instead of re-adding `unstructured` to the default backend dependencies, and the remaining optional simulation lock now resolves `pillow==10.4.0`." + }, + { + "number": 86, + "status": "superseded", + "summary": "Superseded locally by the newer workflow updates already carried on this branch, including the later ARM64/cache improvements." + }, + { + "number": 87, + "status": "superseded", + "summary": "Superseded locally by the broader GitHub Actions upgrade sweep from upstream PR #116." + }, + { + "number": 100, + "status": "superseded", + "summary": "Superseded locally by the shared frontend API base-url resolver, which already uses runtime-origin fallback and repo-specific localhost handling." + }, + { + "number": 101, + "status": "superseded", + "summary": "Superseded locally: the useful JSON-cleanup intent is already covered by the current LLM payload extraction hardening, and the branch predates substantial newer backend/frontend work." + }, + { + "number": 102, + "status": "superseded", + "summary": "Superseded locally by the landed multi-platform Docker workflow that already builds `linux/amd64` and `linux/arm64` images." + }, + { + "number": 103, + "status": "landed", + "summary": "Landed locally: Docker workflow now builds ARM64 images and carries the related cache/buildx improvements." + }, + { + "number": 104, + "status": "landed", + "summary": "Landed locally: Vite dev proxy target is configurable via `VITE_API_BASE_URL`." + }, + { + "number": 105, + "status": "landed", + "summary": "Landed locally: backend security/config hardening now includes env-driven CORS controls with a localhost-only default allowlist, `DEBUG=False` by default, and generated fallback `SECRET_KEY` behavior." + }, + { + "number": 108, + "status": "not_safe", + "summary": "Not safe to cherry-pick: the Windows packaging flow targets the wrong runtime entrypoints, serves the frontend in dev mode, and rebundles already-landed workflow changes." + }, + { + "number": 112, + "status": "landed", + "summary": "Landed locally: Korean README added and cross-links normalized." + }, + { + "number": 113, + "status": "landed", + "summary": "Landed locally: Japanese README added and cross-links normalized." + }, + { + "number": 114, + "status": "superseded", + "summary": "Superseded locally by the current frontend API client, which already falls back to the runtime origin and supports custom base URLs." + }, + { + "number": 115, + "status": "landed", + "summary": "Landed locally: SPDX license string metadata cleanup." + }, + { + "number": 116, + "status": "landed", + "summary": "Landed locally: GitHub Actions dependency upgrades." + }, + { + "number": 118, + "status": "not_safe", + "summary": "Not safe to cherry-pick: RAGflow support threads a second graph backend through core graph/simulation paths without the targeted regression coverage or rebasing needed on top of current local changes." + }, + { + "number": 119, + "status": "partial", + "summary": "Safe subset landed locally for persisted EN/ZH UI chrome, locale headers, browser-language-aware first-run locale bootstrap, deterministic Step 2/3 workflow logs, MainView workflow/build logs, locale-aware report and InsightForge sub-query prompt scaffolding, English-localized ReportAgent tool descriptions and parameter help, bilingual Step 4 Insight/Panorama parsing, deterministic Step 5 interview helper fallbacks/prompts, English-mode Oasis profile prompt empty-state fallbacks, English-localized GraphBuilderService worker progress strings, and English fallback labels in `zep_tools` Panorama/InsightForge report output, but broader backend/runtime localization remains a follow-up instead of a blind merge.", + "local_refs": [ + "frontend/src/views/MainView.vue", + "frontend/src/views/mainViewLogMessages.js", + "backend/app/services/graph_builder.py", + "frontend/src/components/Step4Report.vue", + "frontend/src/components/reportParsers.js", + "backend/app/services/report_agent.py", + "backend/app/services/zep_tools.py", + "backend/app/services/oasis_profile_generator.py", + "frontend/src/i18n/index.js", + "frontend/src/i18n/locales/en.js", + "frontend/src/i18n/locales/zh.js", + "backend/tests/test_graph_builder.py", + "backend/tests/test_report_agent.py", + "backend/tests/test_openai_compat_services.py", + "frontend/tests/i18n.test.mjs", + "frontend/tests/mainViewLogMessages.test.mjs", + "frontend/tests/reportParsers.test.mjs", + "backend/tests/test_zep_tools_i18n.py" + ], + "validation": [ + "uv run --project backend pytest -q backend/tests/test_graph_builder.py backend/tests/test_graph_upload_api.py", + "uv run --project backend pytest -q backend/tests/test_report_agent.py", + "uv run --project backend pytest -q backend/tests/test_openai_compat_services.py", + "uv run --project backend pytest -q backend/tests/test_zep_tools_i18n.py", + "scripts/test_backend_lite.sh", + "frontend: npm test", + "frontend: npm run build" + ] + }, + { + "number": 122, + "status": "landed", + "summary": "Landed locally: removed `response_format={type: json_object}` from `chat_json()` for LM Studio and Ollama compatibility." + }, + { + "number": 124, + "status": "landed", + "summary": "Landed locally: robust JSON extraction for mixed LLM responses." + }, + { + "number": 125, + "status": "landed", + "summary": "Landed locally: improved new-project network error diagnostics in the frontend." + }, + { + "number": 126, + "status": "landed", + "summary": "Safe subset landed locally for structured config validation and non-sensitive config summaries." + }, + { + "number": 127, + "status": "landed", + "summary": "Landed locally: `LLMClient.chat()` already coerces `None` completion content to an empty string before post-processing, and `backend/tests/test_llm_client.py` covers the regression explicitly.", + "local_refs": [ + "backend/app/utils/llm_client.py", + "backend/tests/test_llm_client.py", + "origin/mirror/upstream-pr-127" + ], + "validation": [ + "cd backend && uv run pytest -q tests/test_llm_client.py -k none" + ] + }, + { + "number": 129, + "status": "landed", + "summary": "Safe subset landed locally for context-length retry, configurable `LLM_MAX_TOKENS`, and report-agent history pruning." + }, + { + "number": 130, + "status": "landed", + "summary": "Landed locally: `CONTRIBUTING.md`." + }, + { + "number": 131, + "status": "landed", + "summary": "Safe subset landed locally: transient Zep failures now retry with bounded backoff." + }, + { + "number": 132, + "status": "landed", + "summary": "Landed locally: README architecture overview." + }, + { + "number": 141, + "status": "not_safe", + "summary": "Not safe to cherry-pick: the entity-deduplication branch rewinds large portions of the current tree (tooling, tests, i18n, OpenAI-compatible docs/config) while adding a large graph mutation feature, so it needs a repo-native reimplementation with targeted regression coverage instead of a blind merge.", + "local_refs": [ + "origin/mirror/upstream-pr-141", + ".beads/issues.jsonl" + ], + "validation": [ + "triage diff review", + "git diff --stat HEAD..upstream/pr/141" + ] + }, + { + "number": 143, + "status": "landed", + "summary": "Landed locally as a repo-native docs cleanup: the Shanda logo alt text now uses the correct URL-encoded `666ghj%2FMiroFish` slug across all README variants, not just the primary Chinese README.", + "local_refs": [ + "README.md", + "README-EN.md", + "README-JA.md", + "README-KO.md", + "origin/mirror/upstream-pr-143" + ], + "validation": [ + "documentation review", + "rg -n \"666ghj%2MiroFish|666ghj%2FMiroFish\" README.md README-EN.md README-JA.md README-KO.md" + ] + }, + { + "number": 144, + "status": "tracked", + "summary": "Tracked under beads issue `mirofish-8eg`: the dual-mode local knowledge graph branch is directionally aligned with the non-Zep backend request, but it is not safe to cherry-pick wholesale because it adds a large new adapter plus dependency stack on top of an older tree without current graph/simulation regression coverage.", + "local_refs": [ + ".beads/issues.jsonl", + "origin/mirror/upstream-pr-144" + ], + "validation": [ + "triage diff review", + "git diff --stat HEAD...upstream/pr-144" + ] + }, + { + "number": 152, + "status": "landed", + "summary": "Landed locally as repo-native subsets instead of a wholesale cherry-pick: ontology generation and graph build submission now normalize type names to Zep-compatible PascalCase / SCREAMING_SNAKE_CASE conventions, and TaskManager state now persists to disk so graph/report task status survives backend restarts.", + "local_refs": [ + "backend/app/services/ontology_generator.py", + "backend/tests/test_ontology_generator.py", + "backend/app/services/graph_builder.py", + "backend/tests/test_graph_builder.py", + "backend/app/models/task.py", + "backend/tests/test_task_manager.py", + "origin/mirror/upstream-pr-152", + ".beads/issues.jsonl" + ], + "validation": [ + "cd backend && uv run pytest -q tests/test_ontology_generator.py", + "python3 -m compileall backend/app/services/ontology_generator.py backend/tests/test_ontology_generator.py", + "cd backend && uv run pytest -q tests/test_graph_builder.py", + "python3 -m compileall backend/app/services/graph_builder.py", + "uv run --project backend pytest -q backend/tests/test_task_manager.py backend/tests/test_backend_localized_errors.py", + "python3 -m compileall backend/app/models/task.py backend/tests/test_task_manager.py" + ] + }, + { + "number": 155, + "status": "tracked", + "summary": "Tracked under beads issue `mirofish-2ul1`: the combined backend/frontend/i18n/docker sweep is mirrored into `origin/mirror/upstream-pr-155`, but it rewrites 59 files on an older tree and is not safe to cherry-pick wholesale on top of the current repo-native OpenAI-compat, tests, docs, and partial i18n work.", + "local_refs": [ + ".beads/issues.jsonl", + "origin/mirror/upstream-pr-155", + "docs/upstream-triage.md", + "scripts/sync_upstream_github.py", + "tests/test_sync_upstream_github.py" + ], + "validation": [ + "git diff --stat upstream/main...origin/mirror/upstream-pr-155", + "python3 -m unittest tests.test_sync_upstream_github" + ] + }, + { + "number": 151, + "status": "landed", + "summary": "Landed locally before the upstream PR opened: Twitter-only simulations now infer the active platform instead of silently defaulting to Reddit in retrieval APIs and profile loading, matching the intent of upstream PR #151.", + "local_refs": [ + "backend/app/api/simulation.py", + "backend/app/services/simulation_manager.py", + "backend/tests/test_simulation_service_i18n.py", + "backend/tests/test_simulation_api_i18n.py", + "origin/mirror/upstream-pr-151" + ], + "validation": [ + "cd backend && uv run pytest -q tests/test_simulation_service_i18n.py tests/test_simulation_api_i18n.py", + "python3 -m compileall backend/app/services/simulation_manager.py backend/app/api/simulation.py" + ] + }, + { + "number": 147, + "status": "partial", + "summary": "Safe subset landed locally: added a repo-native `README-RU.md` plus language cross-links, but the full branch is not safe to cherry-pick because it replaces large frontend/backend sections and drops current local tooling, tests, and upstream-triage assets.", + "local_refs": [ + "README.md", + "README-EN.md", + "README-JA.md", + "README-KO.md", + "README-RU.md", + "origin/mirror/upstream-pr-147" + ], + "validation": [ + "documentation review", + "git diff --stat HEAD..mirror/upstream-pr-147" + ] + } + ] +} diff --git a/docs/upstream-open-state.json b/docs/upstream-open-state.json new file mode 100644 index 00000000..91589396 --- /dev/null +++ b/docs/upstream-open-state.json @@ -0,0 +1,4569 @@ +{ + "repo": "666ghj/MiroFish", + "state": "open", + "captured_at": "2026-03-12T04:03:14.445722+00:00", + "generated_at": "2026-03-12T04:03:14.445722+00:00", + "refreshed_at": "2026-03-12T04:03:14.445722+00:00", + "coverage_map_path": "docs/upstream-coverage.json", + "counts": { + "issues": { + "open": 46 + }, + "pull_requests": { + "open": 40 + }, + "mirrored_pull_requests": { + "mirrored": 40, + "not_mirrored": 0 + }, + "mirrored_issues": { + "mirrored": 46, + "not_mirrored": 0 + } + }, + "issues": [ + { + "number": 159, + "title": "太消耗zep了,为啥不考虑自建库呢?", + "url": "https://github.com/666ghj/MiroFish/issues/159", + "state": "open", + "created_at": "2026-03-12T03:20:17Z", + "updated_at": "2026-03-12T03:30:23Z", + "closed_at": null, + "labels": [ + "enhancement" + ], + "author": "codetsang", + "body_excerpt": "zep的额度太低了,要真正进行分析,需要大量的Episode。能否考虑基于其他开源方案,重写zep部分?", + "comment_count": 1, + "recent_comments": [ + { + "author": "chrischeng192", + "created_at": "2026-03-12T03:30:23Z", + "updated_at": "2026-03-12T03:30:23Z", + "url": "https://github.com/666ghj/MiroFish/issues/159#issuecomment-4043666300", + "body_excerpt": "你暂时可以看看[这里](https://github.com/666ghj/MiroFish/issues/56)" + } + ], + "local_coverage": { + "number": 159, + "status": "tracked", + "summary": "Tracked under beads issue `mirofish-zx6p`: another upstream request for a self-hosted or non-Zep graph backend is preserved locally, but implementing it safely still requires the broader graph-backend abstraction work already tracked under `mirofish-8eg` instead of wiring an ad hoc replacement into the current graph/simulation pipeline.", + "local_refs": [ + ".beads/issues.jsonl", + "docs/upstream-triage.md", + "https://github.com/ivanzud/MiroFish/issues/97" + ], + "validation": [ + "tracking only" + ], + "notes": "Mirrored into fork issue #97 on March 12, 2026. This request overlaps the existing non-Zep backend and self-hosted graph follow-ups from upstream issues #55, #76, #106, and #156." + }, + "local_status": "tracked", + "local_summary": "Tracked under beads issue `mirofish-zx6p`: another upstream request for a self-hosted or non-Zep graph backend is preserved locally, but implementing it safely still requires the broader graph-backend abstraction work already tracked under `mirofish-8eg` instead of wiring an ad hoc replacement into the current graph/simulation pipeline.", + "triage_status": "tracked", + "summary": "Tracked under beads issue `mirofish-zx6p`: another upstream request for a self-hosted or non-Zep graph backend is preserved locally, but implementing it safely still requires the broader graph-backend abstraction work already tracked under `mirofish-8eg` instead of wiring an ad hoc replacement into the current graph/simulation pipeline.", + "coverage_status": "tracked", + "coverage_summary": "Tracked under beads issue `mirofish-zx6p`: another upstream request for a self-hosted or non-Zep graph backend is preserved locally, but implementing it safely still requires the broader graph-backend abstraction work already tracked under `mirofish-8eg` instead of wiring an ad hoc replacement into the current graph/simulation pipeline.", + "fork_issue_mirrored": true, + "fork_issue_number": 97, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/97" + }, + { + "number": 158, + "title": "Are there any predictions that have been verified by subsequent events?", + "url": "https://github.com/666ghj/MiroFish/issues/158", + "state": "open", + "created_at": "2026-03-12T01:50:37Z", + "updated_at": "2026-03-12T03:24:05Z", + "closed_at": null, + "labels": [ + "question" + ], + "author": "DuinoDu", + "body_excerpt": "Awesome idea! I am wondering are there any predictions that have been verified by subsequent events?", + "comment_count": 1, + "recent_comments": [ + { + "author": "codetsang", + "created_at": "2026-03-12T03:24:05Z", + "updated_at": "2026-03-12T03:24:05Z", + "url": "https://github.com/666ghj/MiroFish/issues/158#issuecomment-4043646147", + "body_excerpt": "Not yet? Maybe you should give it a try and validate the results. BTW, this is a prediction tool, so there are many uncertainties involved. It should be used more as an analysis or decision-support tool rather than a strict predictor." + } + ], + "local_coverage": { + "number": 158, + "status": "partial", + "summary": "README.md, README-EN.md, README-RU.md, README-KO.md, and README-JA.md now document a repo-native forecast verification workflow, Step 4 surfaces both the stable `report_id` and `simulation_id` with direct copy actions, the homepage history modal keeps those same references together for later review, and both views now also copy a single structured verification bundle so users can preserve the paired references in one paste. Exported Step 4 Markdown still embeds the report/simulation/graph references directly in the file header, and it now also includes stable local report paths plus a localized manual verification checklist so the saved artifact stays actionable outside the UI. Users can export that Markdown from both Step 4 and the saved-history modal or reuse the files under `backend/uploads/reports/<report_id>/` for later comparison against real-world outcomes. MiroFish still does not ship an automatic ground-truth ingester or scoring pipeline, so full backtesting remains tracked under beads issue `mirofish-gytl`.", + "local_refs": [ + ".beads/issues.jsonl", + "README-EN.md", + "README-JA.md", + "README-KO.md", + "README.md", + "README-RU.md", + "backend/app/services/report_agent.py", + "backend/tests/test_report_agent.py", + "docs/upstream-triage.md", + "frontend/src/components/HistoryDatabase.vue", + "frontend/src/components/historyReportDownload.js", + "frontend/src/components/Step4Report.vue", + "frontend/src/components/verificationBundle.js", + "frontend/src/i18n/locales/en.js", + "frontend/src/i18n/locales/zh.js", + "frontend/tests/historyReportDownload.test.mjs", + "frontend/tests/verificationBundle.test.mjs", + "https://github.com/ivanzud/MiroFish/issues/95" + ], + "validation": [ + "cd backend && uv run pytest -q tests/test_report_agent.py -k \"reference_block or embeds_reference_block\"", + "uv run --project backend pytest -q backend/tests/test_report_agent.py backend/tests/test_report_api_i18n.py", + "python3 -m compileall backend/app/services/report_agent.py backend/tests/test_report_agent.py backend/tests/test_report_api_i18n.py", + "bash ./scripts/test_backend_lite.sh", + "npm --prefix frontend test", + "npm --prefix frontend run build", + "README verification" + ], + "notes": "Mirrored into fork issue #95 on March 12, 2026. The repo now documents how to preserve and revisit forecast evidence across the Chinese, English, Russian, Korean, and Japanese README set, exposes copyable report/simulation IDs in Step 4, surfaces the same references plus a direct Markdown export action in the history modal for later reuse, adds a one-click verification bundle copy in both Step 4 and history so those references can be preserved together, and makes the exported Markdown self-identifying with storage paths plus a manual checklist so the verification evidence survives outside the UI. It still does not automate outcome ingestion or accuracy scoring." + }, + "local_status": "partial", + "local_summary": "README.md, README-EN.md, README-RU.md, README-KO.md, and README-JA.md now document a repo-native forecast verification workflow, Step 4 surfaces both the stable `report_id` and `simulation_id` with direct copy actions, the homepage history modal keeps those same references together for later review, and both views now also copy a single structured verification bundle so users can preserve the paired references in one paste. Exported Step 4 Markdown still embeds the report/simulation/graph references directly in the file header, and it now also includes stable local report paths plus a localized manual verification checklist so the saved artifact stays actionable outside the UI. Users can export that Markdown from both Step 4 and the saved-history modal or reuse the files under `backend/uploads/reports/<report_id>/` for later comparison against real-world outcomes. MiroFish still does not ship an automatic ground-truth ingester or scoring pipeline, so full backtesting remains tracked under beads issue `mirofish-gytl`.", + "triage_status": "partial", + "summary": "README.md, README-EN.md, README-RU.md, README-KO.md, and README-JA.md now document a repo-native forecast verification workflow, Step 4 surfaces both the stable `report_id` and `simulation_id` with direct copy actions, the homepage history modal keeps those same references together for later review, and both views now also copy a single structured verification bundle so users can preserve the paired references in one paste. Exported Step 4 Markdown still embeds the report/simulation/graph references directly in the file header, and it now also includes stable local report paths plus a localized manual verification checklist so the saved artifact stays actionable outside the UI. Users can export that Markdown from both Step 4 and the saved-history modal or reuse the files under `backend/uploads/reports/<report_id>/` for later comparison against real-world outcomes. MiroFish still does not ship an automatic ground-truth ingester or scoring pipeline, so full backtesting remains tracked under beads issue `mirofish-gytl`.", + "coverage_status": "partial", + "coverage_summary": "README.md, README-EN.md, README-RU.md, README-KO.md, and README-JA.md now document a repo-native forecast verification workflow, Step 4 surfaces both the stable `report_id` and `simulation_id` with direct copy actions, the homepage history modal keeps those same references together for later review, and both views now also copy a single structured verification bundle so users can preserve the paired references in one paste. Exported Step 4 Markdown still embeds the report/simulation/graph references directly in the file header, and it now also includes stable local report paths plus a localized manual verification checklist so the saved artifact stays actionable outside the UI. Users can export that Markdown from both Step 4 and the saved-history modal or reuse the files under `backend/uploads/reports/<report_id>/` for later comparison against real-world outcomes. MiroFish still does not ship an automatic ground-truth ingester or scoring pipeline, so full backtesting remains tracked under beads issue `mirofish-gytl`.", + "fork_issue_mirrored": true, + "fork_issue_number": 95, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/95" + }, + { + "number": 157, + "title": "如何删除不想要的记录", + "url": "https://github.com/666ghj/MiroFish/issues/157", + "state": "open", + "created_at": "2026-03-12T01:49:07Z", + "updated_at": "2026-03-12T01:51:15Z", + "closed_at": null, + "labels": [ + "question" + ], + "author": "Axing93", + "body_excerpt": "比如我想删除 <img width=\"1835\" height=\"775\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/12332bbc-f309-497b-a352-f0d15289042e\" />这两个,怎么删除呢", + "comment_count": 0, + "recent_comments": [], + "local_coverage": { + "number": 157, + "status": "covered", + "summary": "Homepage history now supports repo-native deletion of unwanted local records. The backend exposes `DELETE /api/simulation/history/<simulation_id>` to remove a simulation's persisted local directory, cascade-delete its attached local reports, and prune the project metadata when no sibling simulations remain, while refusing deletion for active runs. The history modal now exposes a localized delete action that calls that endpoint directly.", + "local_refs": [ + ".beads/issues.jsonl", + "backend/app/api/simulation.py", + "backend/app/i18n.py", + "backend/app/services/simulation_manager.py", + "backend/tests/test_simulation_api_i18n.py", + "frontend/src/api/simulation.js", + "frontend/src/components/HistoryDatabase.vue", + "frontend/src/i18n/locales/en.js", + "frontend/src/i18n/locales/zh.js", + "https://github.com/ivanzud/MiroFish/issues/96" + ], + "validation": [ + "cd backend && uv run pytest -q tests/test_simulation_api_i18n.py", + "bash ./scripts/test_backend_lite.sh", + "npm --prefix frontend run build" + ], + "notes": "Mirrored into fork issue #96 on March 12, 2026. This is a repo-native local-history cleanup flow; it intentionally avoids deleting upstream or Zep-hosted graph data." + }, + "local_status": "covered", + "local_summary": "Homepage history now supports repo-native deletion of unwanted local records. The backend exposes `DELETE /api/simulation/history/<simulation_id>` to remove a simulation's persisted local directory, cascade-delete its attached local reports, and prune the project metadata when no sibling simulations remain, while refusing deletion for active runs. The history modal now exposes a localized delete action that calls that endpoint directly.", + "triage_status": "covered", + "summary": "Homepage history now supports repo-native deletion of unwanted local records. The backend exposes `DELETE /api/simulation/history/<simulation_id>` to remove a simulation's persisted local directory, cascade-delete its attached local reports, and prune the project metadata when no sibling simulations remain, while refusing deletion for active runs. The history modal now exposes a localized delete action that calls that endpoint directly.", + "coverage_status": "covered", + "coverage_summary": "Homepage history now supports repo-native deletion of unwanted local records. The backend exposes `DELETE /api/simulation/history/<simulation_id>` to remove a simulation's persisted local directory, cascade-delete its attached local reports, and prune the project metadata when no sibling simulations remain, while refusing deletion for active runs. The history modal now exposes a localized delete action that calls that endpoint directly.", + "fork_issue_mirrored": true, + "fork_issue_number": 96, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/96" + }, + { + "number": 156, + "title": "能不能不要画zep图?我只要推演和角色互动", + "url": "https://github.com/666ghj/MiroFish/issues/156", + "state": "open", + "created_at": "2026-03-12T01:33:58Z", + "updated_at": "2026-03-12T01:36:11Z", + "closed_at": null, + "labels": [ + "enhancement" + ], + "author": "yiziwu-dm", + "body_excerpt": "zep免费额度轻松就用完了,然后流程卡4/5在生成报告上面", + "comment_count": 0, + "recent_comments": [], + "local_coverage": { + "number": 156, + "status": "tracked", + "summary": "Tracked under beads issue `mirofish-gd5z`: upstream wants a simulation/report workflow that avoids Zep graph dependency entirely. The config-status payload and frontend backend diagnostics now expose a capability matrix that separates the direct `OPENAI_*` / Codex-compatible LLM path from Zep-gated Step 1 graph build and graph-backed Step 4 tooling, `/api/report/generate` now fails early with the same structured backend-config payload instead of launching a doomed async Step 4 task, and the frontend now also exposes a simulation-only Step 5 route plus Step 3/Step 4 CTAs so users can continue directly into role interaction without a report when only `ZEP_API_KEY` is missing. Full non-Zep simulation-only execution still needs a dedicated backend-architecture change.", + "local_refs": [ + ".beads/issues.jsonl", + "backend/app/api/report.py", + "backend/app/config.py", + "backend/tests/test_report_api_i18n.py", + "backend/tests/test_config.py", + "backend/tests/test_print_config_status.py", + "frontend/src/components/interactionRoute.js", + "frontend/src/components/apiConfigDiagnostics.js", + "frontend/src/components/Step3Simulation.vue", + "frontend/src/components/Step4Report.vue", + "frontend/src/components/Step5Interaction.vue", + "frontend/src/i18n/locales/en.js", + "frontend/src/i18n/locales/zh.js", + "frontend/tests/apiConfigDiagnostics.test.mjs", + "frontend/tests/interactionRoute.test.mjs", + "frontend/src/router/index.js", + "frontend/src/views/InteractionView.vue", + "README.md", + "README-EN.md", + "https://github.com/ivanzud/MiroFish/issues/94" + ], + "validation": [ + "cd backend && uv run pytest -q tests/test_config.py tests/test_print_config_status.py", + "uv run --project backend pytest -q backend/tests/test_report_api_i18n.py", + "npm --prefix frontend test -- --runInBand apiConfigDiagnostics.test.mjs", + "npm --prefix frontend test", + "npm --prefix frontend run build", + "env -i PATH=\"$PATH\" HOME=\"$HOME\" TERM=\"$TERM\" OPENAI_API_KEY=codex-test-key OPENAI_API_BASE_URL=https://codex.example.test/v1 OPENAI_MODEL=gpt-4.1-mini ZEP_API_KEY=zep-test-key SECRET_KEY=test-secret npm run check:backend-config -- --compact", + "env -i PATH=\"$PATH\" HOME=\"$HOME\" TERM=\"$TERM\" OPENAI_API_KEY=codex-test-key OPENAI_API_BASE_URL=https://codex.example.test/v1 OPENAI_MODEL=gpt-4.1-mini SECRET_KEY=test-secret npm run check:backend-config -- --compact", + "bash ./scripts/test_backend_lite.sh" + ], + "notes": "Mirrored into fork issue #94 on March 12, 2026. The landed capability matrix makes it explicit that direct OpenAI-compatible LLM wiring can still be valid even when Step 1 / Step 4 remain blocked on Zep, and that Step 5 interaction is still available on an existing simulation environment. This branch now also returns that same non-sensitive config payload directly from `/api/report/generate`, so Step 4 no longer starts a doomed async task when only the direct LLM path is configured, and the frontend now gives users a repo-native escape hatch into `/interaction/simulation/<simulation_id>` from both Step 3 preflight failures and Step 4 failed-report screens." + }, + "local_status": "tracked", + "local_summary": "Tracked under beads issue `mirofish-gd5z`: upstream wants a simulation/report workflow that avoids Zep graph dependency entirely. The config-status payload and frontend backend diagnostics now expose a capability matrix that separates the direct `OPENAI_*` / Codex-compatible LLM path from Zep-gated Step 1 graph build and graph-backed Step 4 tooling, `/api/report/generate` now fails early with the same structured backend-config payload instead of launching a doomed async Step 4 task, and the frontend now also exposes a simulation-only Step 5 route plus Step 3/Step 4 CTAs so users can continue directly into role interaction without a report when only `ZEP_API_KEY` is missing. Full non-Zep simulation-only execution still needs a dedicated backend-architecture change.", + "triage_status": "tracked", + "summary": "Tracked under beads issue `mirofish-gd5z`: upstream wants a simulation/report workflow that avoids Zep graph dependency entirely. The config-status payload and frontend backend diagnostics now expose a capability matrix that separates the direct `OPENAI_*` / Codex-compatible LLM path from Zep-gated Step 1 graph build and graph-backed Step 4 tooling, `/api/report/generate` now fails early with the same structured backend-config payload instead of launching a doomed async Step 4 task, and the frontend now also exposes a simulation-only Step 5 route plus Step 3/Step 4 CTAs so users can continue directly into role interaction without a report when only `ZEP_API_KEY` is missing. Full non-Zep simulation-only execution still needs a dedicated backend-architecture change.", + "coverage_status": "tracked", + "coverage_summary": "Tracked under beads issue `mirofish-gd5z`: upstream wants a simulation/report workflow that avoids Zep graph dependency entirely. The config-status payload and frontend backend diagnostics now expose a capability matrix that separates the direct `OPENAI_*` / Codex-compatible LLM path from Zep-gated Step 1 graph build and graph-backed Step 4 tooling, `/api/report/generate` now fails early with the same structured backend-config payload instead of launching a doomed async Step 4 task, and the frontend now also exposes a simulation-only Step 5 route plus Step 3/Step 4 CTAs so users can continue directly into role interaction without a report when only `ZEP_API_KEY` is missing. Full non-Zep simulation-only execution still needs a dedicated backend-architecture change.", + "fork_issue_mirrored": true, + "fork_issue_number": 94, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/94" + }, + { + "number": 154, + "title": "Profile serialization crashes when LLM returns structured bio/persona fields", + "url": "https://github.com/666ghj/MiroFish/issues/154", + "state": "open", + "created_at": "2026-03-12T00:26:01Z", + "updated_at": "2026-03-12T00:32:38Z", + "closed_at": null, + "labels": [], + "author": "ygh1254", + "body_excerpt": "## Summary When profile generation returns structured JSON objects for fields like `bio`, `persona`, or `country`, MiroFish can fail during profile serialization before config generation starts. ## Reproduction context Observed on a live run with: - simulation_id: `sim_e69a946b6158` - graph_id: `mirofish_a39b5f10127f4744` - entities_count: `91` - status in state file: `failed` - error in state fi…", + "comment_count": 1, + "recent_comments": [ + { + "author": "dosubot[bot]", + "created_at": "2026-03-12T00:32:38Z", + "updated_at": "2026-03-12T00:32:38Z", + "url": "https://github.com/666ghj/MiroFish/issues/154#issuecomment-4043093778", + "body_excerpt": "<!-- Greeting --> Hi @ygh1254! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the MiroFish team. <!-- Answer --> Your analysis is spot on. Looking at the code, the root cause is exactly as you described — the [`OasisAgentProfile`…" + } + ], + "local_coverage": { + "number": 154, + "status": "covered", + "summary": "Profile serialization now tolerates structured LLM output instead of crashing when `bio`, `persona`, `country`, `profession`, or `interested_topics` arrive as dict/list values. `OasisAgentProfile` normalizes those mixed types at construction time and the Reddit/Twitter serializers defensively coerce them again before slicing or string replacement, so simulation preparation no longer fails during profile save with `KeyError: slice(None, 150, None)`.", + "local_refs": [ + ".beads/issues.jsonl", + "backend/app/services/oasis_profile_generator.py", + "backend/tests/test_openai_compat_services.py", + "https://github.com/ivanzud/MiroFish/issues/93" + ], + "validation": [ + "cd backend && uv run pytest -q tests/test_openai_compat_services.py -k \"structured_fields or save_profiles_defaults_country_by_locale or save_twitter_profiles_tolerates_structured_fields\"", + "bash ./scripts/test_backend_lite.sh" + ], + "notes": "Mirrored into fork issue #93 on March 12, 2026. This is a repo-native fix for the newly reported upstream serialization crash rather than a PR cherry-pick." + }, + "local_status": "covered", + "local_summary": "Profile serialization now tolerates structured LLM output instead of crashing when `bio`, `persona`, `country`, `profession`, or `interested_topics` arrive as dict/list values. `OasisAgentProfile` normalizes those mixed types at construction time and the Reddit/Twitter serializers defensively coerce them again before slicing or string replacement, so simulation preparation no longer fails during profile save with `KeyError: slice(None, 150, None)`.", + "triage_status": "covered", + "summary": "Profile serialization now tolerates structured LLM output instead of crashing when `bio`, `persona`, `country`, `profession`, or `interested_topics` arrive as dict/list values. `OasisAgentProfile` normalizes those mixed types at construction time and the Reddit/Twitter serializers defensively coerce them again before slicing or string replacement, so simulation preparation no longer fails during profile save with `KeyError: slice(None, 150, None)`.", + "coverage_status": "covered", + "coverage_summary": "Profile serialization now tolerates structured LLM output instead of crashing when `bio`, `persona`, `country`, `profession`, or `interested_topics` arrive as dict/list values. `OasisAgentProfile` normalizes those mixed types at construction time and the Reddit/Twitter serializers defensively coerce them again before slicing or string replacement, so simulation preparation no longer fails during profile save with `KeyError: slice(None, 150, None)`.", + "fork_issue_mirrored": true, + "fork_issue_number": 93, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/93" + }, + { + "number": 153, + "title": "npm run setup:all安装时一直报 pillow` (v10.3.0) 的错", + "url": "https://github.com/666ghj/MiroFish/issues/153", + "state": "open", + "created_at": "2026-03-11T18:39:28Z", + "updated_at": "2026-03-11T18:41:35Z", + "closed_at": null, + "labels": [ + "question" + ], + "author": "heiheiheibj", + "body_excerpt": "Resolved 188 packages in 5.27s Built mirofish-backend @ file:///D:/MiroFish/backend x Failed to build `pillow==10.3.0` |-> The build backend returned an error `-> Call to `backend.build_wheel` failed (exit code: 1) [stderr] Traceback (most recent call last): File \"<string>\", line 14, in <module> requires = get_requires_for_build({}) File \"C:\\Users\\Administrator\\AppData\\Local\\uv\\cache\\builds-v0\\.t…", + "comment_count": 0, + "recent_comments": [], + "local_coverage": { + "number": 153, + "status": "covered", + "summary": "The current branch no longer reproduces a `pillow` build during the default `npm run setup:all` core install path. `setup:backend` now maps to a plain `uv sync` of the core graph/report/OpenAI-compatible backend dependencies, while the heavyweight simulation runtime remains behind the separate `setup:backend:simulation` entrypoint. A Windows + Python 3.13 dry-run of `uv sync --frozen` against the current lockfile does not attempt to install `pillow` at all, and the optional simulation lock now resolves `pillow==10.4.0` instead of `10.3.0`.", + "local_refs": [ + "package.json", + "README.md", + "README-EN.md", + "scripts/setup_backend_simulation.py", + "backend/uv.lock" + ], + "validation": [ + "cd backend && uv sync --frozen --python 3.13 --python-platform windows --dry-run --output-format json", + "cd backend && uv sync --extra simulation --frozen --dry-run", + "triage diff review" + ], + "notes": "Mirrored into fork issue #92 on March 11, 2026. If the reporter still sees `pillow==10.3.0` during `setup:all`, they are likely on an older checkout. The current core path avoids Pillow entirely and the optional simulation path now locks Pillow to `10.4.0`." + }, + "local_status": "covered", + "local_summary": "The current branch no longer reproduces a `pillow` build during the default `npm run setup:all` core install path. `setup:backend` now maps to a plain `uv sync` of the core graph/report/OpenAI-compatible backend dependencies, while the heavyweight simulation runtime remains behind the separate `setup:backend:simulation` entrypoint. A Windows + Python 3.13 dry-run of `uv sync --frozen` against the current lockfile does not attempt to install `pillow` at all, and the optional simulation lock now resolves `pillow==10.4.0` instead of `10.3.0`.", + "triage_status": "covered", + "summary": "The current branch no longer reproduces a `pillow` build during the default `npm run setup:all` core install path. `setup:backend` now maps to a plain `uv sync` of the core graph/report/OpenAI-compatible backend dependencies, while the heavyweight simulation runtime remains behind the separate `setup:backend:simulation` entrypoint. A Windows + Python 3.13 dry-run of `uv sync --frozen` against the current lockfile does not attempt to install `pillow` at all, and the optional simulation lock now resolves `pillow==10.4.0` instead of `10.3.0`.", + "coverage_status": "covered", + "coverage_summary": "The current branch no longer reproduces a `pillow` build during the default `npm run setup:all` core install path. `setup:backend` now maps to a plain `uv sync` of the core graph/report/OpenAI-compatible backend dependencies, while the heavyweight simulation runtime remains behind the separate `setup:backend:simulation` entrypoint. A Windows + Python 3.13 dry-run of `uv sync --frozen` against the current lockfile does not attempt to install `pillow` at all, and the optional simulation lock now resolves `pillow==10.4.0` instead of `10.3.0`.", + "fork_issue_mirrored": true, + "fork_issue_number": 92, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/92" + }, + { + "number": 150, + "title": "Bug: Hardcoded 'reddit' platform default causes silent data loss for Twitter-only simulations", + "url": "https://github.com/666ghj/MiroFish/issues/150", + "state": "open", + "created_at": "2026-03-11T17:36:38Z", + "updated_at": "2026-03-11T17:49:29Z", + "closed_at": null, + "labels": [], + "author": "karesansui-u", + "body_excerpt": "## Summary When a simulation is created with Twitter-only configuration (`enable_reddit=false`), all data retrieval APIs silently return empty results because they default to looking up Reddit data. No error is raised — the user sees an empty UI with no indication of what went wrong. ## Root Cause The platform parameter defaults to `'reddit'` in 11+ locations across the codebase. When a Twitter-o…", + "comment_count": 0, + "recent_comments": [], + "local_coverage": { + "number": 150, + "status": "covered", + "summary": "Simulation data retrieval now resolves the active platform from `SimulationState` instead of silently defaulting to Reddit when Twitter/X is the only enabled platform, and `SimulationManager.get_profiles()` now reads Twitter profile CSV files correctly as well. That prevents empty profile/post responses in Twitter-only simulations even when older callers still pass the historical `reddit` default.", + "local_refs": [ + ".beads/issues.jsonl", + "backend/app/services/simulation_manager.py", + "backend/app/api/simulation.py", + "backend/tests/test_simulation_service_i18n.py", + "backend/tests/test_simulation_api_i18n.py", + "https://github.com/ivanzud/MiroFish/issues/91" + ], + "validation": [ + "cd backend && uv run pytest -q tests/test_simulation_service_i18n.py tests/test_simulation_api_i18n.py", + "python3 -m compileall backend/app/services/simulation_manager.py backend/app/api/simulation.py" + ], + "notes": "Mirrored into fork issue #91 on March 11, 2026. The fix keeps legacy callers working by falling back to the sole enabled platform instead of assuming Reddit." + }, + "local_status": "covered", + "local_summary": "Simulation data retrieval now resolves the active platform from `SimulationState` instead of silently defaulting to Reddit when Twitter/X is the only enabled platform, and `SimulationManager.get_profiles()` now reads Twitter profile CSV files correctly as well. That prevents empty profile/post responses in Twitter-only simulations even when older callers still pass the historical `reddit` default.", + "triage_status": "covered", + "summary": "Simulation data retrieval now resolves the active platform from `SimulationState` instead of silently defaulting to Reddit when Twitter/X is the only enabled platform, and `SimulationManager.get_profiles()` now reads Twitter profile CSV files correctly as well. That prevents empty profile/post responses in Twitter-only simulations even when older callers still pass the historical `reddit` default.", + "coverage_status": "covered", + "coverage_summary": "Simulation data retrieval now resolves the active platform from `SimulationState` instead of silently defaulting to Reddit when Twitter/X is the only enabled platform, and `SimulationManager.get_profiles()` now reads Twitter profile CSV files correctly as well. That prevents empty profile/post responses in Twitter-only simulations even when older callers still pass the historical `reddit` default.", + "fork_issue_mirrored": true, + "fork_issue_number": 91, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/91" + }, + { + "number": 149, + "title": "一直卡在 Waiting for agent actions", + "url": "https://github.com/666ghj/MiroFish/issues/149", + "state": "open", + "created_at": "2026-03-11T16:56:33Z", + "updated_at": "2026-03-11T17:00:02Z", + "closed_at": null, + "labels": [ + "question" + ], + "author": "jidancong", + "body_excerpt": "<img width=\"947\" height=\"398\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/09b45da5-150c-4d3b-82c0-6ba2204c1743\" />", + "comment_count": 1, + "recent_comments": [ + { + "author": "dosubot[bot]", + "created_at": "2026-03-11T17:00:02Z", + "updated_at": "2026-03-11T17:00:02Z", + "url": "https://github.com/666ghj/MiroFish/issues/149#issuecomment-4040690744", + "body_excerpt": "<!-- Greeting --> Hi @jidancong! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the MiroFish team. <!-- Answer --> 这个问题通常是因为后端的 agent 动作数据没有正确生成或传递到前端。以下是几个常见原因和排查建议: **1. 检查 LLM API 配置** 最常见的原因是 [API URL 格式不正确](https://github.com…" + } + ], + "local_coverage": { + "number": 149, + "status": "covered", + "summary": "Step 3 now reconciles stale persisted `running` states when the worker PID is gone, and the detailed status payload exposes compact simulation-log diagnostics while waiting for the first actions. That prevents indefinite \"Waiting for agent actions\" polling after a dead worker and makes true startup stalls visible in the UI.", + "local_refs": [ + "backend/app/services/simulation_runner.py", + "backend/app/api/simulation.py", + "backend/tests/test_simulation_runner_actions.py", + "backend/tests/test_simulation_run_status_detail.py", + "frontend/src/components/Step3Simulation.vue" + ], + "validation": [ + "cd backend && uv run pytest -q tests/test_simulation_runner_actions.py tests/test_simulation_run_status_detail.py", + "npm --prefix frontend test", + "npm --prefix frontend run build" + ] + }, + "local_status": "covered", + "local_summary": "Step 3 now reconciles stale persisted `running` states when the worker PID is gone, and the detailed status payload exposes compact simulation-log diagnostics while waiting for the first actions. That prevents indefinite \"Waiting for agent actions\" polling after a dead worker and makes true startup stalls visible in the UI.", + "triage_status": "covered", + "summary": "Step 3 now reconciles stale persisted `running` states when the worker PID is gone, and the detailed status payload exposes compact simulation-log diagnostics while waiting for the first actions. That prevents indefinite \"Waiting for agent actions\" polling after a dead worker and makes true startup stalls visible in the UI.", + "coverage_status": "covered", + "coverage_summary": "Step 3 now reconciles stale persisted `running` states when the worker PID is gone, and the detailed status payload exposes compact simulation-log diagnostics while waiting for the first actions. That prevents indefinite \"Waiting for agent actions\" polling after a dead worker and makes true startup stalls visible in the UI.", + "fork_issue_mirrored": true, + "fork_issue_number": 90, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/90" + }, + { + "number": 146, + "title": "[Feature Request] Add Husky for Git Hook Automated Checks", + "url": "https://github.com/666ghj/MiroFish/issues/146", + "state": "open", + "created_at": "2026-03-11T15:12:43Z", + "updated_at": "2026-03-11T15:16:25Z", + "closed_at": null, + "labels": [ + "enhancement" + ], + "author": "fishwww-ww", + "body_excerpt": "Background The current project lacks automated validation before code commits, which may lead to the following issues: 1. Committing non-compliant code (e.g., syntax errors, messy formatting); 2. Inconsistent commit messages, which is not conducive to subsequent maintenance and version tracking; 3. Inefficiency in team collaboration due to the need for manual reminders of specifications. Solution…", + "comment_count": 0, + "recent_comments": [], + "local_coverage": { + "number": 146, + "status": "covered", + "summary": "The repo now ships an opt-in, repo-native git hook workflow: `.githooks/pre-commit` runs the shared fast validation bundle, `.githooks/pre-push` runs the full validation bundle, and `npm run hooks:install` enables them without introducing a mandatory Husky/Node-only hook dependency.", + "local_refs": [ + ".githooks/pre-commit", + ".githooks/pre-push", + "scripts/validate_repo.sh", + "scripts/install_git_hooks.sh", + "CONTRIBUTING.md", + "package.json", + "https://github.com/ivanzud/MiroFish/issues/88" + ], + "validation": [ + "bash ./scripts/validate_repo.sh --backend-only", + "bash ./scripts/validate_repo.sh --frontend-only", + "bash ./scripts/install_git_hooks.sh --help" + ], + "notes": "Mirrored into fork issue #88 on March 11, 2026. The workflow is intentionally opt-in and uses git core.hooksPath instead of requiring Husky." + }, + "local_status": "covered", + "local_summary": "The repo now ships an opt-in, repo-native git hook workflow: `.githooks/pre-commit` runs the shared fast validation bundle, `.githooks/pre-push` runs the full validation bundle, and `npm run hooks:install` enables them without introducing a mandatory Husky/Node-only hook dependency.", + "triage_status": "covered", + "summary": "The repo now ships an opt-in, repo-native git hook workflow: `.githooks/pre-commit` runs the shared fast validation bundle, `.githooks/pre-push` runs the full validation bundle, and `npm run hooks:install` enables them without introducing a mandatory Husky/Node-only hook dependency.", + "coverage_status": "covered", + "coverage_summary": "The repo now ships an opt-in, repo-native git hook workflow: `.githooks/pre-commit` runs the shared fast validation bundle, `.githooks/pre-push` runs the full validation bundle, and `npm run hooks:install` enables them without introducing a mandatory Husky/Node-only hook dependency.", + "fork_issue_mirrored": true, + "fork_issue_number": 88, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/88" + }, + { + "number": 145, + "title": "知识图谱中存在重复实体节点", + "url": "https://github.com/666ghj/MiroFish/issues/145", + "state": "open", + "created_at": "2026-03-11T14:43:11Z", + "updated_at": "2026-03-11T14:53:58Z", + "closed_at": null, + "labels": [], + "author": "Stayfoool", + "body_excerpt": "## 问题描述 在使用 MiroFish 构建知识图谱时,Zep 会将同一现实实体识别为多个不同节点。 例如输入包含\"特朗普\"相关内容的文本后,图谱中会同时出现\"特朗普\"和 \"美国总统特朗普\"两个独立节点,它们各自有独立的边和关系。 这会导致: - 图谱中同一实体的信息被分散到多个节点上 - 后续的模拟推演基于不完整的实体关系进行,影响准确性 - 图谱可视化时出现冗余节点,影响可读性 ## 复现步骤 1. 准备一段包含同一人物/组织不同称呼的背景文本 2. 通过前端正常流程构建知识图谱 3. 查看生成的图谱,可以看到同一实体被拆分为多个节点 ## 截图 <img width=\"675\" height=\"399\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/593f4188-e766-46b3-9b88-25486…", + "comment_count": 0, + "recent_comments": [], + "local_coverage": { + "number": 145, + "status": "partial", + "summary": "Repo-native partial mitigations are now landed locally across simulation inputs, backend graph/report/search/statistics/detail surfaces, raw graph introspection, node-edge introspection, textual tool output, both shipped graph renderers, and the visible frontend graph counters/logs: `ZepEntityReader.filter_defined_entities()` collapses obvious same-entity alias variants before simulation/profile generation, `ZepEntityReader.get_entity_with_context()` now merges alias-linked relations and related nodes for the entity-detail API, `backend/app/services/graph_builder.py` now collapses the same conservative alias pairs when serving `/api/graph/data/<graph_id>` and remaps duplicate edges to the retained node UUID, `backend/app/services/zep_tools.py` now collapses those aliases when building typed entity lists, raw node/edge introspection payloads, Panorama output, InsightForge entity/relationship summaries, QuickSearch/general search results, graph statistics, node-edge lookups, entity summaries including relations attached only to alias UUIDs, and `NodeInfo.to_text()` output, while preserving merged `alias_names` metadata so callers and downstream prompts can still see which labels were folded together. `frontend/src/views/processGraphData.js` and the shared `frontend/src/components/GraphPanel.vue` renderer now both collapse them while rendering graph data, the Process plus GraphPanel node detail drawers expose the folded non-canonical aliases via `frontend/src/components/graphAliasDetails.js`, and `frontend/src/components/graphPanelData.js` now drives deduplicated Step 1 / Process counters plus MainView refresh logs so title-prefixed duplicates such as `美国总统特朗普` vs `特朗普` no longer appear twice in the graph or its visible counts. Full graph-level persisted deduplication still remains tracked under beads issue `mirofish-975` because upstream PR #141 is not safe to cherry-pick wholesale.", + "local_refs": [ + ".beads/issues.jsonl", + "docs/upstream-triage.md", + "origin/mirror/upstream-pr-141", + "backend/app/services/zep_entity_reader.py", + "backend/tests/test_zep_entity_reader.py", + "backend/app/services/graph_builder.py", + "backend/tests/test_graph_builder.py", + "backend/app/services/zep_tools.py", + "backend/tests/test_zep_tools_dedup.py", + "backend/tests/test_zep_tools_i18n.py", + "frontend/src/components/GraphPanel.vue", + "frontend/src/components/graphAliasDetails.js", + "frontend/src/components/graphPanelData.js", + "frontend/src/components/Step1GraphBuild.vue", + "frontend/tests/graphAliasDetails.test.mjs", + "frontend/tests/graphPanelData.test.mjs", + "frontend/src/views/MainView.vue", + "frontend/src/views/Process.vue", + "frontend/src/views/processGraphData.js", + "frontend/tests/processGraphData.test.mjs" + ], + "validation": [ + "cd backend && uv run pytest -q tests/test_graph_builder.py tests/test_zep_entity_reader.py tests/test_zep_tools_dedup.py tests/test_zep_tools_i18n.py", + "python3 -m compileall backend/app/services/zep_entity_reader.py backend/app/services/graph_builder.py backend/app/services/zep_tools.py", + "bash ./scripts/test_backend_lite.sh", + "frontend: npm --prefix frontend test", + "frontend: npm --prefix frontend run build", + "triage diff review" + ], + "notes": "Mirrored into fork issue tracking on March 11, 2026 after enabling issues on ivanzud/MiroFish. 2026-03-11: Added a third repo-native partial mitigation in backend report/search tooling so conservative alias pairs are collapsed before Step 4/analysis surfaces render entity summaries and relationship chains. 2026-03-11: Added a fourth repo-native partial mitigation in backend graph-data responses so `/api/graph/data/<graph_id>` collapses the same obvious alias pairs and remaps duplicate edges without mutating stored Zep nodes. 2026-03-11: Added a fifth repo-native partial mitigation in QuickSearch/general search results so `search_graph()` and the local-search fallback collapse the same obvious alias pairs, deduplicate summary facts, and remap duplicate edges before report tooling or API callers consume the result payload. 2026-03-11: Added a seventh repo-native partial mitigation in backend graph statistics and entity-summary helpers so alias duplicates no longer inflate stats counts and alias queries resolve to the canonical merged entity payload. 2026-03-11: Added an eighth repo-native partial mitigation in backend entity summaries so alias-linked relations are collected from the full edge set before canonical remapping, which prevents summaries from dropping edges attached only to an alias UUID. 2026-03-11: Added a ninth repo-native partial mitigation in backend entity-detail reads so `get_entity_with_context()` resolves the requested node against its alias group, includes alias-linked relations, and deduplicates related-node payloads. 2026-03-11: Added a tenth repo-native partial mitigation in zep_tools node-edge lookups so `get_node_edges()` now resolves the requested node against its alias group and remaps duplicate edges instead of dropping relationships attached only to an alias UUID. 2026-03-11: Added an eleventh repo-native partial mitigation in raw zep_tools graph introspection so `get_all_nodes()` and `get_all_edges()` now collapse obvious alias duplicates and remap duplicate edge payloads before report-side callers consume the graph snapshot. 2026-03-11: Added a fourteenth repo-native partial mitigation in textual zep_tools node rendering so `NodeInfo.to_text()` now includes merged alias names with locale-aware labels, preserving the folded names in downstream report/runtime prompts. 2026-03-11: Added a fifteenth repo-native partial mitigation in the shared frontend graph renderer so `frontend/src/components/GraphPanel.vue` now reuses the conservative display-only alias-collapse mapper and no longer shows obvious duplicate entities outside the Process view. 2026-03-11: Added a sixteenth repo-native partial mitigation in the Process and shared GraphPanel node detail drawers so `frontend/src/components/graphAliasDetails.js` filters merged `alias_names` down to the folded non-canonical labels and both detail panels render them explicitly instead of hiding which source names collapsed into the canonical node. 2026-03-11: Added a seventeenth repo-native partial mitigation in frontend graph stats so `frontend/src/components/graphPanelData.js` now drives Step 1 / Process counters and MainView refresh logs through the same alias-collapse mapping as the visible graph renderer." + }, + "local_status": "partial", + "local_summary": "Repo-native partial mitigations are now landed locally across simulation inputs, backend graph/report/search/statistics/detail surfaces, raw graph introspection, node-edge introspection, textual tool output, both shipped graph renderers, and the visible frontend graph counters/logs: `ZepEntityReader.filter_defined_entities()` collapses obvious same-entity alias variants before simulation/profile generation, `ZepEntityReader.get_entity_with_context()` now merges alias-linked relations and related nodes for the entity-detail API, `backend/app/services/graph_builder.py` now collapses the same conservative alias pairs when serving `/api/graph/data/<graph_id>` and remaps duplicate edges to the retained node UUID, `backend/app/services/zep_tools.py` now collapses those aliases when building typed entity lists, raw node/edge introspection payloads, Panorama output, InsightForge entity/relationship summaries, QuickSearch/general search results, graph statistics, node-edge lookups, entity summaries including relations attached only to alias UUIDs, and `NodeInfo.to_text()` output, while preserving merged `alias_names` metadata so callers and downstream prompts can still see which labels were folded together. `frontend/src/views/processGraphData.js` and the shared `frontend/src/components/GraphPanel.vue` renderer now both collapse them while rendering graph data, the Process plus GraphPanel node detail drawers expose the folded non-canonical aliases via `frontend/src/components/graphAliasDetails.js`, and `frontend/src/components/graphPanelData.js` now drives deduplicated Step 1 / Process counters plus MainView refresh logs so title-prefixed duplicates such as `美国总统特朗普` vs `特朗普` no longer appear twice in the graph or its visible counts. Full graph-level persisted deduplication still remains tracked under beads issue `mirofish-975` because upstream PR #141 is not safe to cherry-pick wholesale.", + "triage_status": "partial", + "summary": "Repo-native partial mitigations are now landed locally across simulation inputs, backend graph/report/search/statistics/detail surfaces, raw graph introspection, node-edge introspection, textual tool output, both shipped graph renderers, and the visible frontend graph counters/logs: `ZepEntityReader.filter_defined_entities()` collapses obvious same-entity alias variants before simulation/profile generation, `ZepEntityReader.get_entity_with_context()` now merges alias-linked relations and related nodes for the entity-detail API, `backend/app/services/graph_builder.py` now collapses the same conservative alias pairs when serving `/api/graph/data/<graph_id>` and remaps duplicate edges to the retained node UUID, `backend/app/services/zep_tools.py` now collapses those aliases when building typed entity lists, raw node/edge introspection payloads, Panorama output, InsightForge entity/relationship summaries, QuickSearch/general search results, graph statistics, node-edge lookups, entity summaries including relations attached only to alias UUIDs, and `NodeInfo.to_text()` output, while preserving merged `alias_names` metadata so callers and downstream prompts can still see which labels were folded together. `frontend/src/views/processGraphData.js` and the shared `frontend/src/components/GraphPanel.vue` renderer now both collapse them while rendering graph data, the Process plus GraphPanel node detail drawers expose the folded non-canonical aliases via `frontend/src/components/graphAliasDetails.js`, and `frontend/src/components/graphPanelData.js` now drives deduplicated Step 1 / Process counters plus MainView refresh logs so title-prefixed duplicates such as `美国总统特朗普` vs `特朗普` no longer appear twice in the graph or its visible counts. Full graph-level persisted deduplication still remains tracked under beads issue `mirofish-975` because upstream PR #141 is not safe to cherry-pick wholesale.", + "coverage_status": "partial", + "coverage_summary": "Repo-native partial mitigations are now landed locally across simulation inputs, backend graph/report/search/statistics/detail surfaces, raw graph introspection, node-edge introspection, textual tool output, both shipped graph renderers, and the visible frontend graph counters/logs: `ZepEntityReader.filter_defined_entities()` collapses obvious same-entity alias variants before simulation/profile generation, `ZepEntityReader.get_entity_with_context()` now merges alias-linked relations and related nodes for the entity-detail API, `backend/app/services/graph_builder.py` now collapses the same conservative alias pairs when serving `/api/graph/data/<graph_id>` and remaps duplicate edges to the retained node UUID, `backend/app/services/zep_tools.py` now collapses those aliases when building typed entity lists, raw node/edge introspection payloads, Panorama output, InsightForge entity/relationship summaries, QuickSearch/general search results, graph statistics, node-edge lookups, entity summaries including relations attached only to alias UUIDs, and `NodeInfo.to_text()` output, while preserving merged `alias_names` metadata so callers and downstream prompts can still see which labels were folded together. `frontend/src/views/processGraphData.js` and the shared `frontend/src/components/GraphPanel.vue` renderer now both collapse them while rendering graph data, the Process plus GraphPanel node detail drawers expose the folded non-canonical aliases via `frontend/src/components/graphAliasDetails.js`, and `frontend/src/components/graphPanelData.js` now drives deduplicated Step 1 / Process counters plus MainView refresh logs so title-prefixed duplicates such as `美国总统特朗普` vs `特朗普` no longer appear twice in the graph or its visible counts. Full graph-level persisted deduplication still remains tracked under beads issue `mirofish-975` because upstream PR #141 is not safe to cherry-pick wholesale.", + "fork_issue_mirrored": true, + "fork_issue_number": 2, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/2" + }, + { + "number": 142, + "title": "这个方向最后商业化落地应用的点是什么呢", + "url": "https://github.com/666ghj/MiroFish/issues/142", + "state": "open", + "created_at": "2026-03-11T14:01:19Z", + "updated_at": "2026-03-11T14:03:26Z", + "closed_at": null, + "labels": [ + "question" + ], + "author": "jack1234-byte", + "body_excerpt": "", + "comment_count": 0, + "recent_comments": [], + "local_coverage": { + "number": 142, + "status": "no_action", + "summary": "Upstream issue #142 asks about long-term commercialization direction rather than reporting a reproducible bug or scoped feature request, so there is no local implementation task attached to it.", + "local_refs": [ + "docs/upstream-open-state.json" + ], + "validation": [ + "triage only" + ] + }, + "local_status": "no_action", + "local_summary": "Upstream issue #142 asks about long-term commercialization direction rather than reporting a reproducible bug or scoped feature request, so there is no local implementation task attached to it.", + "triage_status": "no_action", + "summary": "Upstream issue #142 asks about long-term commercialization direction rather than reporting a reproducible bug or scoped feature request, so there is no local implementation task attached to it.", + "coverage_status": "no_action", + "coverage_summary": "Upstream issue #142 asks about long-term commercialization direction rather than reporting a reproducible bug or scoped feature request, so there is no local implementation task attached to it.", + "fork_issue_mirrored": true, + "fork_issue_number": 3, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/3" + }, + { + "number": 64, + "title": "一直卡在上传文件错误:Request failed with status code 500", + "url": "https://github.com/666ghj/MiroFish/issues/64", + "state": "open", + "created_at": "2026-01-31T13:10:06Z", + "updated_at": "2026-03-11T13:50:29Z", + "closed_at": null, + "labels": [], + "author": "G-LJDS2022", + "body_excerpt": "<img width=\"1206\" height=\"1234\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/5befa186-6f0f-493a-a6fa-7fb33940f233\" /> TXT、MD、PDF文件格式都试了,内容甚至精简到就几百字,但就是卡在上传文件错误,到底什么原因?", + "comment_count": 15, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-02-02T07:16:30Z", + "updated_at": "2026-02-02T07:16:30Z", + "url": "https://github.com/666ghj/MiroFish/issues/64#issuecomment-3833393441", + "body_excerpt": "以前的代码因为编码格式的缘故会报这样的错,最新代码已经修复了。 你是把他部署在服务器上吗,那好像会有一些问题。" + }, + { + "author": "wjh-w", + "created_at": "2026-02-05T07:52:32Z", + "updated_at": "2026-02-05T07:52:32Z", + "url": "https://github.com/666ghj/MiroFish/issues/64#issuecomment-3851616788", + "body_excerpt": "我也遇到这样的问题上传文件 的时候一直处于 500的问题 <img width=\"1051\" height=\"356\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/536df0ba-f13a-4dcf-a588-455f9c4f1084\" />" + }, + { + "author": "HAN-GITWarehouse", + "created_at": "2026-02-07T03:21:04Z", + "updated_at": "2026-02-07T03:21:04Z", + "url": "https://github.com/666ghj/MiroFish/issues/64#issuecomment-3863455964", + "body_excerpt": "这个 500的报错 你要看下你前端界面 调用generate 这个接口的反馈 结果反馈是什么吧 我之前也是500的 报错 原因在调用这个接口的时候 发现 因为zep cloude 只提供 秘钥 但是不提供模型 而 项目中的模型 你要需要去申请一个没药来使用 很多模型也并不是免费的 但是一般 新用户注册 都有免费额度" + } + ], + "local_coverage": { + "number": 64, + "status": "covered", + "summary": "Upload and graph-build failures now surface structured per-file parser/config validation errors instead of collapsing common deployment or document-ingest problems into a generic 500.", + "local_refs": [ + "backend/app/api/graph.py", + "backend/app/config.py", + "frontend/src/views/Process.vue", + "backend/tests/test_graph_upload_api.py" + ], + "validation": [ + "scripts/test_backend_lite.sh", + "frontend: npm test", + "frontend: npm run build" + ] + }, + "local_status": "covered", + "local_summary": "Upload and graph-build failures now surface structured per-file parser/config validation errors instead of collapsing common deployment or document-ingest problems into a generic 500.", + "triage_status": "covered", + "summary": "Upload and graph-build failures now surface structured per-file parser/config validation errors instead of collapsing common deployment or document-ingest problems into a generic 500.", + "coverage_status": "covered", + "coverage_summary": "Upload and graph-build failures now surface structured per-file parser/config validation errors instead of collapsing common deployment or document-ingest problems into a generic 500.", + "fork_issue_mirrored": true, + "fork_issue_number": 4, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/4" + }, + { + "number": 140, + "title": "让我想到了刘慈欣的一个小说,要是把真实世界放进去,得多大的算力啊", + "url": "https://github.com/666ghj/MiroFish/issues/140", + "state": "open", + "created_at": "2026-03-11T11:20:00Z", + "updated_at": "2026-03-11T11:20:00Z", + "closed_at": null, + "labels": [], + "author": "p20061", + "body_excerpt": "", + "comment_count": 0, + "recent_comments": [], + "local_coverage": { + "number": 140, + "status": "no_action", + "summary": "Upstream issue #140 is general project commentary rather than an actionable defect report or scoped feature request, so it does not require local implementation work.", + "local_refs": [ + "docs/upstream-open-state.json" + ], + "validation": [ + "triage only" + ] + }, + "local_status": "no_action", + "local_summary": "Upstream issue #140 is general project commentary rather than an actionable defect report or scoped feature request, so it does not require local implementation work.", + "triage_status": "no_action", + "summary": "Upstream issue #140 is general project commentary rather than an actionable defect report or scoped feature request, so it does not require local implementation work.", + "coverage_status": "no_action", + "coverage_summary": "Upstream issue #140 is general project commentary rather than an actionable defect report or scoped feature request, so it does not require local implementation work.", + "fork_issue_mirrored": true, + "fork_issue_number": 5, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/5" + }, + { + "number": 135, + "title": "报错,Zep图谱构建失败", + "url": "https://github.com/666ghj/MiroFish/issues/135", + "state": "open", + "created_at": "2026-03-11T08:51:14Z", + "updated_at": "2026-03-11T08:55:24Z", + "closed_at": null, + "labels": [], + "author": "rheeh", + "body_excerpt": "Graph build task failed: Traceback (most recent call last): File \"/app/backend/app/api/graph.py\", line 418, in build_task builder.set_ontology(graph_id, ontology) File \"/app/backend/app/services/graph_builder.py\", line 230, in set_ontology attr_name = safe_attr_name(attr_def[\"name\"]) # 使用安全名称 ~~~~~~~~^^^^^^^^ TypeError: string indices must be integers, not 'str'", + "comment_count": 1, + "recent_comments": [ + { + "author": "dosubot[bot]", + "created_at": "2026-03-11T08:55:24Z", + "updated_at": "2026-03-11T08:55:24Z", + "url": "https://github.com/666ghj/MiroFish/issues/135#issuecomment-4037553350", + "body_excerpt": "<!-- Greeting --> Hi @rheeh! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the MiroFish team. <!-- Answer --> 这个错误是因为 `attr_def` 应该是字典格式 `{\"name\": \"attr_name\", ...}`,但实际收到的是字符串。 **根本原因**:LLM 生成的 ontology 中,`attributes` 字段可能返回了简单的…" + } + ], + "local_coverage": { + "number": 135, + "status": "covered", + "summary": "Graph ontology ingestion now accepts string-valued attribute definitions for entity and edge schemas, so malformed LLM ontology output no longer crashes graph builds with `TypeError: string indices must be integers`.", + "local_refs": [ + "backend/app/services/graph_builder.py", + "backend/tests/test_graph_builder.py" + ], + "validation": [ + "backend/tests/test_graph_builder.py::test_set_ontology_accepts_string_attribute_definitions" + ], + "notes": "Mirrored into fork issue tracking on March 11, 2026 after enabling issues on ivanzud/MiroFish." + }, + "local_status": "covered", + "local_summary": "Graph ontology ingestion now accepts string-valued attribute definitions for entity and edge schemas, so malformed LLM ontology output no longer crashes graph builds with `TypeError: string indices must be integers`.", + "triage_status": "covered", + "summary": "Graph ontology ingestion now accepts string-valued attribute definitions for entity and edge schemas, so malformed LLM ontology output no longer crashes graph builds with `TypeError: string indices must be integers`.", + "coverage_status": "covered", + "coverage_summary": "Graph ontology ingestion now accepts string-valued attribute definitions for entity and edge schemas, so malformed LLM ontology output no longer crashes graph builds with `TypeError: string indices must be integers`.", + "fork_issue_mirrored": true, + "fork_issue_number": 6, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/6" + }, + { + "number": 133, + "title": "本地部署,启动已经显示成功了,访问却失败", + "url": "https://github.com/666ghj/MiroFish/issues/133", + "state": "open", + "created_at": "2026-03-11T07:45:16Z", + "updated_at": "2026-03-11T07:47:36Z", + "closed_at": null, + "labels": [ + "question" + ], + "author": "Axing93", + "body_excerpt": "前后端启动都显示成功了,0.0.0.0:5001无法访问,提示404。前端0.0.0.0:3000虽然可以访问,但是一到这个界面就会报错 <img width=\"2550\" height=\"1233\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/032cf0ac-78ae-406d-831d-da5b9a28d5a0\" />,看了项目也没有报错日志,请问这是什么问题呢", + "comment_count": 1, + "recent_comments": [ + { + "author": "dosubot[bot]", + "created_at": "2026-03-11T07:47:36Z", + "updated_at": "2026-03-11T07:47:36Z", + "url": "https://github.com/666ghj/MiroFish/issues/133#issuecomment-4037175500", + "body_excerpt": "<!-- Greeting --> Hi @Axing93! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the MiroFish team. <!-- Answer --> 这是一个常见的前后端连接配置问题。 **后端 5001 返回 404 是正常的** — [根路径 `/` 未定义端点](https://github.com/666ghj/MiroFish/issues/68),实际 API 通过具体…" + } + ], + "local_coverage": { + "number": 133, + "status": "covered", + "summary": "The backend root path `/` plus `/health` and `/healthz` now return a small JSON status payload with the live API prefixes, so local or Docker users no longer need to infer backend health from a bare 404.", + "local_refs": [ + "backend/app/__init__.py", + "backend/tests/test_app_routes.py" + ], + "validation": [ + "backend/tests/test_app_routes.py::test_root_and_health_endpoints_expose_backend_status" + ], + "notes": "Mirrored into fork issue tracking on March 11, 2026 after enabling issues on ivanzud/MiroFish." + }, + "local_status": "covered", + "local_summary": "The backend root path `/` plus `/health` and `/healthz` now return a small JSON status payload with the live API prefixes, so local or Docker users no longer need to infer backend health from a bare 404.", + "triage_status": "covered", + "summary": "The backend root path `/` plus `/health` and `/healthz` now return a small JSON status payload with the live API prefixes, so local or Docker users no longer need to infer backend health from a bare 404.", + "coverage_status": "covered", + "coverage_summary": "The backend root path `/` plus `/health` and `/healthz` now return a small JSON status payload with the live API prefixes, so local or Docker users no longer need to infer backend health from a bare 404.", + "fork_issue_mirrored": true, + "fork_issue_number": 7, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/7" + }, + { + "number": 117, + "title": "### Feature Request: English Language Support", + "url": "https://github.com/666ghj/MiroFish/issues/117", + "state": "open", + "created_at": "2026-03-10T08:44:18Z", + "updated_at": "2026-03-10T08:46:24Z", + "closed_at": null, + "labels": [ + "enhancement" + ], + "author": "malpaniga", + "body_excerpt": "Hi, First of all, thank you for creating and open-sourcing this amazing project. MiroFish is a very interesting and powerful multi-agent prediction engine. Currently, a large portion of the documentation, UI text, and comments appear to be primarily in Chinese. This makes it difficult for international developers to fully understand and use the project. ### Request It would be very helpful if the…", + "comment_count": 0, + "recent_comments": [], + "local_coverage": { + "number": 117, + "status": "covered", + "summary": "The English support sweep now covers workflow chrome, deterministic Step 2/3 system-log copy, graph-build worker progress, report/interview parsing, Step 5 interview fallbacks, and simulation-config placeholder labels: Step 3/5 labels flow through shared i18n dictionaries, Step 2 prepare-stage progress and Step 3 round/PID logs localize through shared helpers, GraphBuilderService now persists English task-status strings for its initial worker milestones, Step 4 tool-output parsers accept both Chinese and English markers, zep_tools localizes deterministic interview-selection/question/summary fallback copy in English mode, and SimulationConfigGenerator now routes unknown entity/poster fallback labels through the active locale instead of leaking hardcoded English `Unknown` into zh-mode config output.", + "local_refs": [ + "frontend/src/components/Step2EnvSetup.vue", + "frontend/src/components/Step3Simulation.vue", + "backend/app/services/graph_builder.py", + "frontend/src/components/Step4Report.vue", + "frontend/src/components/Step5Interaction.vue", + "frontend/src/components/reportParsers.js", + "frontend/src/components/simulationLogMessages.js", + "frontend/src/components/step5Profiles.js", + "frontend/src/components/simulationTimeline.js", + "backend/app/services/zep_tools.py", + "backend/app/services/simulation_config_generator.py", + "frontend/src/i18n/locales/en.js", + "frontend/src/i18n/locales/zh.js", + "backend/tests/test_graph_builder.py", + "backend/tests/test_openai_compat_services.py", + "frontend/tests/simulationLogMessages.test.mjs", + "backend/tests/test_zep_tools_i18n.py" + ], + "validation": [ + "backend/tests/test_graph_builder.py", + "backend/tests/test_openai_compat_services.py", + "frontend/tests/reportParsers.test.mjs", + "frontend/tests/simulationLogMessages.test.mjs", + "frontend/tests/simulationTimeline.test.mjs", + "frontend/tests/step5Profiles.test.mjs", + "backend/tests/test_zep_tools_i18n.py", + "frontend: npm test", + "frontend: npm run build", + "scripts/test_backend_lite.sh" + ], + "notes": "Mirrored into fork issue tracking on March 11, 2026 after enabling issues on ivanzud/MiroFish." + }, + "local_status": "covered", + "local_summary": "The English support sweep now covers workflow chrome, deterministic Step 2/3 system-log copy, graph-build worker progress, report/interview parsing, Step 5 interview fallbacks, and simulation-config placeholder labels: Step 3/5 labels flow through shared i18n dictionaries, Step 2 prepare-stage progress and Step 3 round/PID logs localize through shared helpers, GraphBuilderService now persists English task-status strings for its initial worker milestones, Step 4 tool-output parsers accept both Chinese and English markers, zep_tools localizes deterministic interview-selection/question/summary fallback copy in English mode, and SimulationConfigGenerator now routes unknown entity/poster fallback labels through the active locale instead of leaking hardcoded English `Unknown` into zh-mode config output.", + "triage_status": "covered", + "summary": "The English support sweep now covers workflow chrome, deterministic Step 2/3 system-log copy, graph-build worker progress, report/interview parsing, Step 5 interview fallbacks, and simulation-config placeholder labels: Step 3/5 labels flow through shared i18n dictionaries, Step 2 prepare-stage progress and Step 3 round/PID logs localize through shared helpers, GraphBuilderService now persists English task-status strings for its initial worker milestones, Step 4 tool-output parsers accept both Chinese and English markers, zep_tools localizes deterministic interview-selection/question/summary fallback copy in English mode, and SimulationConfigGenerator now routes unknown entity/poster fallback labels through the active locale instead of leaking hardcoded English `Unknown` into zh-mode config output.", + "coverage_status": "covered", + "coverage_summary": "The English support sweep now covers workflow chrome, deterministic Step 2/3 system-log copy, graph-build worker progress, report/interview parsing, Step 5 interview fallbacks, and simulation-config placeholder labels: Step 3/5 labels flow through shared i18n dictionaries, Step 2 prepare-stage progress and Step 3 round/PID logs localize through shared helpers, GraphBuilderService now persists English task-status strings for its initial worker milestones, Step 4 tool-output parsers accept both Chinese and English markers, zep_tools localizes deterministic interview-selection/question/summary fallback copy in English mode, and SimulationConfigGenerator now routes unknown entity/poster fallback labels through the active locale instead of leaking hardcoded English `Unknown` into zh-mode config output.", + "fork_issue_mirrored": true, + "fork_issue_number": 8, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/8" + }, + { + "number": 110, + "title": "阿里云百炼 API 调用异常:付费计划(Coding Plan)非千文模型及大模型API中转站的API均失效,仅免费额度模型或coding plan的千文模型可用", + "url": "https://github.com/666ghj/MiroFish/issues/110", + "state": "open", + "created_at": "2026-03-10T01:08:29Z", + "updated_at": "2026-03-10T06:40:37Z", + "closed_at": null, + "labels": [ + "LLM API" + ], + "author": "weilb", + "body_excerpt": "阿里云百炼 API 调用异常:付费计划(Coding Plan)非千文模型及大模型API中转站的API均失效,仅免费额度模型或coding plan的千文模型可用", + "comment_count": 1, + "recent_comments": [ + { + "author": "lukeliu95", + "created_at": "2026-03-10T06:40:37Z", + "updated_at": "2026-03-10T06:40:37Z", + "url": "https://github.com/666ghj/MiroFish/issues/110#issuecomment-4029067259", + "body_excerpt": "使用以下方式调用Coding Plan LLM_BASE_URL=https://coding.dashscope.aliyuncs.com/v1 LLM_MODEL_NAME=qwen3.5-plus" + } + ], + "local_coverage": { + "number": 110, + "status": "covered", + "summary": "The backend and docs now support direct OpenAI-compatible gateways plus OPENAI_* aliases, including a documented DashScope Coding Plan example, so users no longer need a provider-specific raw LLM setup path.", + "local_refs": [ + "README.md", + "README-EN.md", + ".env.example", + "backend/app/config.py", + "backend/app/api/graph.py", + "backend/tests/test_openai_compat_services.py" + ], + "validation": [ + "backend/tests/test_openai_compat_services.py::test_simulation_config_generator_missing_api_key_mentions_openai_alias", + "backend/tests/test_openai_compat_services.py::test_simulation_config_generator_missing_api_key_english_message" + ], + "notes": "Mirrored into fork issue tracking on March 11, 2026 after enabling issues on ivanzud/MiroFish." + }, + "local_status": "covered", + "local_summary": "The backend and docs now support direct OpenAI-compatible gateways plus OPENAI_* aliases, including a documented DashScope Coding Plan example, so users no longer need a provider-specific raw LLM setup path.", + "triage_status": "covered", + "summary": "The backend and docs now support direct OpenAI-compatible gateways plus OPENAI_* aliases, including a documented DashScope Coding Plan example, so users no longer need a provider-specific raw LLM setup path.", + "coverage_status": "covered", + "coverage_summary": "The backend and docs now support direct OpenAI-compatible gateways plus OPENAI_* aliases, including a documented DashScope Coding Plan example, so users no longer need a provider-specific raw LLM setup path.", + "fork_issue_mirrored": true, + "fork_issue_number": 9, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/9" + }, + { + "number": 106, + "title": "能否采用除了zep的别的知识图谱", + "url": "https://github.com/666ghj/MiroFish/issues/106", + "state": "open", + "created_at": "2026-03-09T13:55:59Z", + "updated_at": "2026-03-10T02:33:21Z", + "closed_at": null, + "labels": [], + "author": "paipaiio", + "body_excerpt": "如题所示,今天在跑的时候发现zep的免费额度被耗光了,能否添加使用本地部署的memv作为知识图谱", + "comment_count": 2, + "recent_comments": [ + { + "author": "addisjeams", + "created_at": "2026-03-09T17:29:02Z", + "updated_at": "2026-03-09T17:29:02Z", + "url": "https://github.com/666ghj/MiroFish/issues/106#issuecomment-4025506309", + "body_excerpt": "对,一开始半天都是网络报错,后来才发现是这个问题。必须要申请zep" + }, + { + "author": "xingjia10086", + "created_at": "2026-03-10T02:33:21Z", + "updated_at": "2026-03-10T02:33:21Z", + "url": "https://github.com/666ghj/MiroFish/issues/106#issuecomment-4028182818", + "body_excerpt": "作者写的很明确 必须依靠ZEP哈 我也碰到一样的问题 没办法 只能换一个KEY" + } + ], + "local_coverage": { + "number": 106, + "status": "tracked", + "summary": "Tracked under beads issue `mirofish-8eg`: requests for non-Zep graph backends are preserved locally, but implementing them safely requires a fresh graph-backend abstraction instead of wiring another provider into current graph/simulation flows ad hoc.", + "local_refs": [ + ".beads/issues.jsonl", + "docs/upstream-triage.md" + ], + "validation": [ + "tracking only" + ] + }, + "local_status": "tracked", + "local_summary": "Tracked under beads issue `mirofish-8eg`: requests for non-Zep graph backends are preserved locally, but implementing them safely requires a fresh graph-backend abstraction instead of wiring another provider into current graph/simulation flows ad hoc.", + "triage_status": "tracked", + "summary": "Tracked under beads issue `mirofish-8eg`: requests for non-Zep graph backends are preserved locally, but implementing them safely requires a fresh graph-backend abstraction instead of wiring another provider into current graph/simulation flows ad hoc.", + "coverage_status": "tracked", + "coverage_summary": "Tracked under beads issue `mirofish-8eg`: requests for non-Zep graph backends are preserved locally, but implementing them safely requires a fresh graph-backend abstraction instead of wiring another provider into current graph/simulation flows ad hoc.", + "fork_issue_mirrored": true, + "fork_issue_number": 10, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/10" + }, + { + "number": 42, + "title": "项目在3/5开始模拟时会消耗大量内存", + "url": "https://github.com/666ghj/MiroFish/issues/42", + "state": "open", + "created_at": "2026-01-21T01:57:50Z", + "updated_at": "2026-03-09T17:28:20Z", + "closed_at": null, + "labels": [], + "author": "s1f102500012", + "body_excerpt": "作为可能会用到的信息,我上传了大约有260000字符的《白夜行》前十二章。推测原因是simulation.py的接口会把所有动作读全量并返回,这些动作会随着模拟变大而线性膨胀,从而导致巨量内存消耗。 <img width=\"988\" height=\"666\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/3fce3699-5e8d-4a8c-a33f-5290b236a2f0\" />", + "comment_count": 2, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-01-21T07:14:28Z", + "updated_at": "2026-01-21T07:14:28Z", + "url": "https://github.com/666ghj/MiroFish/issues/42#issuecomment-3776532125", + "body_excerpt": "我后续会进行算法层面的优化" + }, + { + "author": "xingjia10086", + "created_at": "2026-03-09T17:28:20Z", + "updated_at": "2026-03-09T17:28:20Z", + "url": "https://github.com/666ghj/MiroFish/issues/42#issuecomment-4025501526", + "body_excerpt": "你的Zep的API够用吗 ?" + } + ], + "local_coverage": { + "number": 42, + "status": "covered", + "summary": "Step 3 detailed run polling is now incremental and bounded, so the frontend no longer re-downloads the entire simulation timeline every few seconds as a run grows.", + "local_refs": [ + "backend/app/api/simulation.py", + "frontend/src/components/Step3Simulation.vue", + "frontend/src/components/simulationTimeline.js" + ], + "validation": [ + "scripts/test_backend_lite.sh", + "frontend: npm test", + "frontend: npm run build" + ] + }, + "local_status": "covered", + "local_summary": "Step 3 detailed run polling is now incremental and bounded, so the frontend no longer re-downloads the entire simulation timeline every few seconds as a run grows.", + "triage_status": "covered", + "summary": "Step 3 detailed run polling is now incremental and bounded, so the frontend no longer re-downloads the entire simulation timeline every few seconds as a run grows.", + "coverage_status": "covered", + "coverage_summary": "Step 3 detailed run polling is now incremental and bounded, so the frontend no longer re-downloads the entire simulation timeline every few seconds as a run grows.", + "fork_issue_mirrored": true, + "fork_issue_number": 11, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/11" + }, + { + "number": 84, + "title": "报告生成失败,请问有没有办法重新生成?", + "url": "https://github.com/666ghj/MiroFish/issues/84", + "state": "open", + "created_at": "2026-03-08T08:10:41Z", + "updated_at": "2026-03-09T17:26:28Z", + "closed_at": null, + "labels": [ + "question" + ], + "author": "luchenwei9266", + "body_excerpt": "<img width=\"2538\" height=\"1213\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/e3d01822-aa09-45b7-9210-2ce8e23bca8f\" /> 好像是Zep超出调用限额导致报告生成失败,但是即使我充值了Zep,似乎也没有办法重新生成报告,难道只能重新走一轮?", + "comment_count": 2, + "recent_comments": [ + { + "author": "dosubot[bot]", + "created_at": "2026-03-08T08:12:58Z", + "updated_at": "2026-03-08T08:12:58Z", + "url": "https://github.com/666ghj/MiroFish/issues/84#issuecomment-4018615761", + "body_excerpt": "<!-- Greeting --> Hi @luchenwei9266! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the MiroFish team. <!-- Answer --> 目前 MiroFish **不支持通过界面重试报告生成**,这是一个[已知问题](https://github.com/666ghj/MiroFish/issues/30)。系统虽然有 `force_regenerate`…" + }, + { + "author": "xingjia10086", + "created_at": "2026-03-09T17:24:34Z", + "updated_at": "2026-03-09T17:26:28Z", + "url": "https://github.com/666ghj/MiroFish/issues/84#issuecomment-4025475607", + "body_excerpt": "<img width=\"1242\" height=\"707\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/871bbcaa-85e2-4f1c-a714-cc038fdf6fd7\" /> 我这跑了一个任务还没结束 ZEP的API就用完了 用了默认的设置 40 ,现在很尴尬的状态 接下来应该怎么才能继续呢? @dosu 难道只能重新注册一个Zep 再跑一遍流程吗?" + } + ], + "local_coverage": { + "number": 84, + "status": "covered", + "summary": "Failed report generation can now be retried directly from Step 4 with persisted status polling, backend error text, and a `force_regenerate` retry path.", + "local_refs": [ + "frontend/src/components/Step4Report.vue", + "frontend/src/api/report.js" + ], + "validation": [ + "frontend: npm run build" + ] + }, + "local_status": "covered", + "local_summary": "Failed report generation can now be retried directly from Step 4 with persisted status polling, backend error text, and a `force_regenerate` retry path.", + "triage_status": "covered", + "summary": "Failed report generation can now be retried directly from Step 4 with persisted status polling, backend error text, and a `force_regenerate` retry path.", + "coverage_status": "covered", + "coverage_summary": "Failed report generation can now be retried directly from Step 4 with persisted status polling, backend error text, and a `force_regenerate` retry path.", + "fork_issue_mirrored": true, + "fork_issue_number": 12, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/12" + }, + { + "number": 107, + "title": "镜像问题", + "url": "https://github.com/666ghj/MiroFish/issues/107", + "state": "open", + "created_at": "2026-03-09T14:44:45Z", + "updated_at": "2026-03-09T14:44:45Z", + "closed_at": null, + "labels": [], + "author": "zhuhw19", + "body_excerpt": "✘ Image ghcr.io/666ghj/mirofish:latest Error Get \"https://ghcr.io/v2/\": EOF 7.9s Error response from daemon: Get \"https://ghcr.io/v2/\": EOF", + "comment_count": 0, + "recent_comments": [], + "local_coverage": { + "number": 107, + "status": "covered", + "summary": "Docker deployment now reads `MIROFISH_IMAGE` from `.env` or the shell, so GHCR pull failures can be worked around with a mirror/private registry override instead of editing `docker-compose.yml`.", + "local_refs": [ + "docker-compose.yml", + ".env.example", + "README.md", + "README-EN.md", + "README-JA.md", + "README-KO.md" + ], + "validation": [ + "docker compose config" + ], + "notes": "Mirrored into fork issue tracking on March 11, 2026 after enabling issues on ivanzud/MiroFish." + }, + "local_status": "covered", + "local_summary": "Docker deployment now reads `MIROFISH_IMAGE` from `.env` or the shell, so GHCR pull failures can be worked around with a mirror/private registry override instead of editing `docker-compose.yml`.", + "triage_status": "covered", + "summary": "Docker deployment now reads `MIROFISH_IMAGE` from `.env` or the shell, so GHCR pull failures can be worked around with a mirror/private registry override instead of editing `docker-compose.yml`.", + "coverage_status": "covered", + "coverage_summary": "Docker deployment now reads `MIROFISH_IMAGE` from `.env` or the shell, so GHCR pull failures can be worked around with a mirror/private registry override instead of editing `docker-compose.yml`.", + "fork_issue_mirrored": true, + "fork_issue_number": 13, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/13" + }, + { + "number": 99, + "title": "Docker镜像没有arm版本", + "url": "https://github.com/666ghj/MiroFish/issues/99", + "state": "open", + "created_at": "2026-03-09T06:52:35Z", + "updated_at": "2026-03-09T06:54:58Z", + "closed_at": null, + "labels": [ + "enhancement" + ], + "author": "linqiu919", + "body_excerpt": "arm架构机器使用docker-compose部署提示: `no matching manifest for linux/arm64/v8 in the manifest list entries ` 可以调整一下工作流的脚本,支持一下么?", + "comment_count": 1, + "recent_comments": [ + { + "author": "dosubot[bot]", + "created_at": "2026-03-09T06:54:58Z", + "updated_at": "2026-03-09T06:54:58Z", + "url": "https://github.com/666ghj/MiroFish/issues/99#issuecomment-4021549881", + "body_excerpt": "<!-- Answer --> 好消息!当前的工作流 [`.github/workflows/docker-image.yml`](https://github.com/666ghj/MiroFish/blob/985f89f49acbb44ee14d9d680682c741a44eeebe/.github/workflows/docker-image.yml) 已经配置了 QEMU 和 Buildx(支持多架构构建的必要组件),只需要在构建步骤中添加 `platforms…" + } + ], + "local_coverage": { + "number": 99, + "status": "covered", + "summary": "Docker image publishing now builds both `linux/amd64` and `linux/arm64`, so the missing ARM image issue is already resolved locally.", + "local_refs": [ + ".github/workflows/docker-image.yml" + ], + "validation": [ + "workflow review only" + ] + }, + "local_status": "covered", + "local_summary": "Docker image publishing now builds both `linux/amd64` and `linux/arm64`, so the missing ARM image issue is already resolved locally.", + "triage_status": "covered", + "summary": "Docker image publishing now builds both `linux/amd64` and `linux/arm64`, so the missing ARM image issue is already resolved locally.", + "coverage_status": "covered", + "coverage_summary": "Docker image publishing now builds both `linux/amd64` and `linux/arm64`, so the missing ARM image issue is already resolved locally.", + "fork_issue_mirrored": true, + "fork_issue_number": 14, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/14" + }, + { + "number": 93, + "title": "`frontend/src/api/index.js`中的`baseURL`不应该硬编码", + "url": "https://github.com/666ghj/MiroFish/issues/93", + "state": "open", + "created_at": "2026-03-08T16:44:24Z", + "updated_at": "2026-03-09T01:53:47Z", + "closed_at": null, + "labels": [ + "enhancement" + ], + "author": "HexStan", + "body_excerpt": "https://github.com/666ghj/MiroFish/blob/985f89f49acbb44ee14d9d680682c741a44eeebe/frontend/src/api/index.js#L5 如#59 #57 所发现的一样,在上传文件时前端会尝试跳转硬编码的`localhost:5001`,这就意味着我只能在部署mirofish的本机上使用,并且在Docker部署时也不能映射其他的端口,对服务器环境很不友好。我代码水平不够,不知道应该怎么修,所以希望作者可以修一下,谢谢!", + "comment_count": 1, + "recent_comments": [ + { + "author": "hxx221", + "created_at": "2026-03-09T01:53:47Z", + "updated_at": "2026-03-09T01:53:47Z", + "url": "https://github.com/666ghj/MiroFish/issues/93#issuecomment-4020607805", + "body_excerpt": "VITE_API_BASE_URL 你修改这个配置就可以了吧,那个5001是容错的,没有VITE_API_BASE_URL配置才会启用这个吧" + } + ], + "local_coverage": { + "number": 93, + "status": "covered", + "summary": "The frontend no longer hardcodes `http://localhost:5001` in project-init messaging and instead uses the shared API base-url resolver.", + "local_refs": [ + "frontend/src/views/Process.vue", + "frontend/src/api/baseUrl.js" + ], + "validation": [ + "frontend/tests/baseUrl.test.mjs", + "frontend: npm run build" + ] + }, + "local_status": "covered", + "local_summary": "The frontend no longer hardcodes `http://localhost:5001` in project-init messaging and instead uses the shared API base-url resolver.", + "triage_status": "covered", + "summary": "The frontend no longer hardcodes `http://localhost:5001` in project-init messaging and instead uses the shared API base-url resolver.", + "coverage_status": "covered", + "coverage_summary": "The frontend no longer hardcodes `http://localhost:5001` in project-init messaging and instead uses the shared API base-url resolver.", + "fork_issue_mirrored": true, + "fork_issue_number": 15, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/15" + }, + { + "number": 92, + "title": "Upgrade GitHub Actions", + "url": "https://github.com/666ghj/MiroFish/issues/92", + "state": "open", + "created_at": "2026-03-08T15:26:04Z", + "updated_at": "2026-03-08T15:28:09Z", + "closed_at": null, + "labels": [ + "enhancement" + ], + "author": "leon-x-labs", + "body_excerpt": "", + "comment_count": 0, + "recent_comments": [], + "local_coverage": { + "number": 92, + "status": "covered", + "summary": "The current GitHub Actions workflow already includes the later upgrade sweep from upstream PR #116, so this issue is stale on this branch.", + "local_refs": [ + ".github/workflows/docker-image.yml" + ], + "validation": [ + "workflow review only" + ] + }, + "local_status": "covered", + "local_summary": "The current GitHub Actions workflow already includes the later upgrade sweep from upstream PR #116, so this issue is stale on this branch.", + "triage_status": "covered", + "summary": "The current GitHub Actions workflow already includes the later upgrade sweep from upstream PR #116, so this issue is stale on this branch.", + "coverage_status": "covered", + "coverage_summary": "The current GitHub Actions workflow already includes the later upgrade sweep from upstream PR #116, so this issue is stale on this branch.", + "fork_issue_mirrored": true, + "fork_issue_number": 16, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/16" + }, + { + "number": 56, + "title": "Zep 本地化实现方案交流专贴", + "url": "https://github.com/666ghj/MiroFish/issues/56", + "state": "open", + "created_at": "2026-01-23T07:24:57Z", + "updated_at": "2026-03-06T01:40:22Z", + "closed_at": null, + "labels": [], + "author": "666ghj", + "body_excerpt": "很多小伙伴已经实现了自己的 Zep 本地化版本,这非常酷!为了方便社区查阅及参考,特开此 Issue 进行统一的展示与讨论。", + "comment_count": 7, + "recent_comments": [ + { + "author": "jstdoit", + "created_at": "2026-01-23T07:40:33Z", + "updated_at": "2026-01-23T07:40:33Z", + "url": "https://github.com/666ghj/MiroFish/issues/56#issuecomment-3788811442", + "body_excerpt": "好多小伙伴虽然实现了,但是效果一般啊,很多效果都没达到zep的" + }, + { + "author": "666ghj", + "created_at": "2026-01-23T07:43:29Z", + "updated_at": "2026-01-23T07:45:28Z", + "url": "https://github.com/666ghj/MiroFish/issues/56#issuecomment-3788820464", + "body_excerpt": "Issue: https://github.com/666ghj/MiroFish/issues/55#issue-3845610782 https://github.com/666ghj/MiroFish/issues/41#issuecomment-3776456714 https://github.com/666ghj/MiroFish/issues/35#issue-3828698112 PR: https://github.com/666ghj/MiroFish/…" + }, + { + "author": "666ghj", + "created_at": "2026-01-23T07:44:09Z", + "updated_at": "2026-01-23T07:44:09Z", + "url": "https://github.com/666ghj/MiroFish/issues/56#issuecomment-3788822532", + "body_excerpt": "> 好多小伙伴虽然实现了,但是效果一般啊,很多效果都没达到zep的 是的,可以再广泛的调研一下,agent记忆方面的论文很多" + } + ], + "local_coverage": { + "number": 56, + "status": "reference", + "summary": "Upstream issue #56 is a community discussion thread collecting localized Zep alternatives. It is retained as reference material while any concrete backend-abstraction work stays tracked under `mirofish-8eg`.", + "local_refs": [ + ".beads/issues.jsonl", + "docs/upstream-open-state.json" + ], + "validation": [ + "triage only" + ] + }, + "local_status": "reference", + "local_summary": "Upstream issue #56 is a community discussion thread collecting localized Zep alternatives. It is retained as reference material while any concrete backend-abstraction work stays tracked under `mirofish-8eg`.", + "triage_status": "reference", + "summary": "Upstream issue #56 is a community discussion thread collecting localized Zep alternatives. It is retained as reference material while any concrete backend-abstraction work stays tracked under `mirofish-8eg`.", + "coverage_status": "reference", + "coverage_summary": "Upstream issue #56 is a community discussion thread collecting localized Zep alternatives. It is retained as reference material while any concrete backend-abstraction work stays tracked under `mirofish-8eg`.", + "fork_issue_mirrored": true, + "fork_issue_number": 17, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/17" + }, + { + "number": 68, + "title": "尝试进行首次模拟,等待了1夜还是在3/5的阶段", + "url": "https://github.com/666ghj/MiroFish/issues/68", + "state": "open", + "created_at": "2026-02-10T01:19:08Z", + "updated_at": "2026-03-01T08:29:56Z", + "closed_at": null, + "labels": [ + "question" + ], + "author": "GarretRen", + "body_excerpt": "<img width=\"1917\" height=\"1884\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/a3b1f1c4-b406-4918-8c7a-7065e7d8dabb\" /> 我尝试进行了一次模拟,选择40轮,需要进行多久呢?此处的60min每轮,代表需要真实世界的60min吗?昨天放了一晚上也没好,看起来控制台在重复输出这样的内容 <img width=\"1734\" height=\"2887\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/cf505633-93ae-432c-99a3-85fae480919d\" /> 另外我在尝试弄清楚发生了什么的时候,尝试打开后端api界面(http://localhost:5001/)…", + "comment_count": 2, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-02-28T08:06:05Z", + "updated_at": "2026-02-28T08:06:05Z", + "url": "https://github.com/666ghj/MiroFish/issues/68#issuecomment-3976635156", + "body_excerpt": "看一下后端文件夹中的模拟日志,类似于在backend/uploads/simulations/sim_id/simulation.log,控制台显示的是日志信息获取接口200,你这个应该是模拟任务出bug了,因为他内部用了机器学习模型做推荐算法,盲猜是从huggingface上拉模型的时候网络错误了。你有更多反馈的话可以贴出来,我再看看。" + }, + { + "author": "GarretRen", + "created_at": "2026-03-01T08:29:56Z", + "updated_at": "2026-03-01T08:29:56Z", + "url": "https://github.com/666ghj/MiroFish/issues/68#issuecomment-3979489505", + "body_excerpt": "感谢帮助,确实是网络问题。在使用全局vpn之后,问题解决了" + } + ], + "local_coverage": { + "number": 68, + "status": "covered", + "summary": "Simulation process-exit errors now classify common HuggingFace download/proxy failures into a concise retry/proxy guidance message instead of dumping raw log tails into Step 3.", + "local_refs": [ + "backend/app/services/simulation_runner.py", + "backend/app/i18n.py", + "backend/tests/test_simulation_runner_actions.py" + ], + "validation": [ + "backend/tests/test_simulation_runner_actions.py", + "backend/tests/test_simulation_api_i18n.py", + "backend/tests/test_i18n.py" + ] + }, + "local_status": "covered", + "local_summary": "Simulation process-exit errors now classify common HuggingFace download/proxy failures into a concise retry/proxy guidance message instead of dumping raw log tails into Step 3.", + "triage_status": "covered", + "summary": "Simulation process-exit errors now classify common HuggingFace download/proxy failures into a concise retry/proxy guidance message instead of dumping raw log tails into Step 3.", + "coverage_status": "covered", + "coverage_summary": "Simulation process-exit errors now classify common HuggingFace download/proxy failures into a concise retry/proxy guidance message instead of dumping raw log tails into Step 3.", + "fork_issue_mirrored": true, + "fork_issue_number": 18, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/18" + }, + { + "number": 75, + "title": "Zep 的免费限速会让进度卡99%", + "url": "https://github.com/666ghj/MiroFish/issues/75", + "state": "open", + "created_at": "2026-02-26T08:35:41Z", + "updated_at": "2026-02-28T08:13:33Z", + "closed_at": null, + "labels": [], + "author": "fengkuangyibo", + "body_excerpt": "", + "comment_count": 2, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-02-28T08:09:36Z", + "updated_at": "2026-02-28T08:09:36Z", + "url": "https://github.com/666ghj/MiroFish/issues/75#issuecomment-3976640239", + "body_excerpt": "> No description provided. 现在重试机制好像写的不太行,zep卡了以后就要重跑了,这部分我最近优化一下,可以先试一个小一点的文档,把流程先跑通。稍微大一点的任务充一个zep会员额度够够的,用不完,我觉得还是很划算的。也可以用完以后换一个邮箱,他是按照邮箱送每月额度的。 实在不行邮箱联系我,我自己充了一些额度,给你测试用一下。" + }, + { + "author": "666ghj", + "created_at": "2026-02-28T08:13:21Z", + "updated_at": "2026-02-28T08:13:33Z", + "url": "https://github.com/666ghj/MiroFish/issues/75#issuecomment-3976645572", + "body_excerpt": "现在主页挂了一个在线演示demo,老哥感兴趣可以玩一下:https://666ghj.github.io/mirofish-demo/" + } + ], + "local_coverage": { + "number": 75, + "status": "covered", + "summary": "Graph-builder retries now honor bounded Zep `Retry-After` guidance from `429` responses instead of always using the default backoff schedule, reducing the stuck-at-99% failure mode when Zep free-plan throttling delays graph processing.", + "local_refs": [ + "backend/app/config.py", + "backend/app/services/graph_builder.py", + "backend/tests/test_graph_builder.py" + ], + "validation": [ + "backend/tests/test_graph_builder.py::test_create_graph_respects_retry_after_header", + "backend/tests/test_graph_builder.py::test_create_graph_respects_retry_after_text_hint", + "backend/tests/test_graph_builder.py::test_create_graph_caps_retry_after_delay", + "scripts/test_backend_lite.sh" + ], + "notes": "Mirrored into fork issue tracking on March 11, 2026 after enabling issues on ivanzud/MiroFish." + }, + "local_status": "covered", + "local_summary": "Graph-builder retries now honor bounded Zep `Retry-After` guidance from `429` responses instead of always using the default backoff schedule, reducing the stuck-at-99% failure mode when Zep free-plan throttling delays graph processing.", + "triage_status": "covered", + "summary": "Graph-builder retries now honor bounded Zep `Retry-After` guidance from `429` responses instead of always using the default backoff schedule, reducing the stuck-at-99% failure mode when Zep free-plan throttling delays graph processing.", + "coverage_status": "covered", + "coverage_summary": "Graph-builder retries now honor bounded Zep `Retry-After` guidance from `429` responses instead of always using the default backoff schedule, reducing the stuck-at-99% failure mode when Zep free-plan throttling delays graph processing.", + "fork_issue_mirrored": true, + "fork_issue_number": 19, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/19" + }, + { + "number": 76, + "title": "知识图谱构建提供接入 ragflow api 的功能", + "url": "https://github.com/666ghj/MiroFish/issues/76", + "state": "open", + "created_at": "2026-02-27T12:02:39Z", + "updated_at": "2026-02-28T08:02:11Z", + "closed_at": null, + "labels": [ + "Memory Layer" + ], + "author": "Hitomogami", + "body_excerpt": "目前知识图谱的生成依赖于 zep 的api 接口,整个过程较为黑箱,同时免费额度有限,能否考虑接入 ragflow 的 api? ragflow 是一个本地部署很方便的项目,可以通过 api 发起知识图谱创建的请求,也可以手动制作知识图谱后直接通过 api 调用,自定义程度高,且使用的都是通用大模型 api,成本更可控,自定义程度更高。 ragflow 项目地址:https://github.com/infiniflow/ragflow", + "comment_count": 1, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-02-28T08:02:10Z", + "updated_at": "2026-02-28T08:02:10Z", + "url": "https://github.com/666ghj/MiroFish/issues/76#issuecomment-3976629901", + "body_excerpt": "感谢反馈,我近期看一下是否合适" + } + ], + "local_coverage": { + "number": 76, + "status": "tracked", + "summary": "Tracked under beads issue `mirofish-8eg`: RAGflow support remains a backend-abstraction/rebase task and is not safe to land via the stale upstream branch without targeted regression coverage.", + "local_refs": [ + ".beads/issues.jsonl", + "docs/upstream-triage.md" + ], + "validation": [ + "tracking only" + ] + }, + "local_status": "tracked", + "local_summary": "Tracked under beads issue `mirofish-8eg`: RAGflow support remains a backend-abstraction/rebase task and is not safe to land via the stale upstream branch without targeted regression coverage.", + "triage_status": "tracked", + "summary": "Tracked under beads issue `mirofish-8eg`: RAGflow support remains a backend-abstraction/rebase task and is not safe to land via the stale upstream branch without targeted regression coverage.", + "coverage_status": "tracked", + "coverage_summary": "Tracked under beads issue `mirofish-8eg`: RAGflow support remains a backend-abstraction/rebase task and is not safe to land via the stale upstream branch without targeted regression coverage.", + "fork_issue_mirrored": true, + "fork_issue_number": 20, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/20" + }, + { + "number": 9, + "title": "可以设置中断重新加载吗", + "url": "https://github.com/666ghj/MiroFish/issues/9", + "state": "open", + "created_at": "2025-12-28T13:16:54Z", + "updated_at": "2026-02-21T09:55:20Z", + "closed_at": null, + "labels": [], + "author": "LMG-arch", + "body_excerpt": "希望可以中断重新加载,有时候调用大模型会出错就要重新开始了,", + "comment_count": 4, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2025-12-31T03:40:35Z", + "updated_at": "2025-12-31T03:40:35Z", + "url": "https://github.com/666ghj/MiroFish/issues/9#issuecomment-3701372628", + "body_excerpt": "> 希望可以中断重新加载,有时候调用大模型会出错就要重新开始了, 好,可以具体说一下场景吗,是在模拟过程中还是前面生成配置的过程中,或者是后面report的环节?因为我记得运行时加了重试机制的嘞。" + }, + { + "author": "LMG-arch", + "created_at": "2025-12-31T03:43:45Z", + "updated_at": "2025-12-31T03:43:45Z", + "url": "https://github.com/666ghj/MiroFish/issues/9#issuecomment-3701377365", + "body_excerpt": "> > 希望可以中断重新加载,有时候调用大模型会出错就要重新开始了, > > 好,可以具体说一下场景吗,是在模拟过程中还是前面生成配置的过程中,或者是后面report的环节?因为我记得运行时加了重试机制的嘞。 在模拟过程中,我使用阿里百炼的时候免费额度用完以后他就断了,重新配置启动以后就要重新模拟了" + }, + { + "author": "666ghj", + "created_at": "2025-12-31T19:33:31Z", + "updated_at": "2025-12-31T19:33:31Z", + "url": "https://github.com/666ghj/MiroFish/issues/9#issuecomment-3702763655", + "body_excerpt": "> > > 希望可以中断重新加载,有时候调用大模型会出错就要重新开始了, > > > > > > 好,可以具体说一下场景吗,是在模拟过程中还是前面生成配置的过程中,或者是后面report的环节?因为我记得运行时加了重试机制的嘞。 > > 在模拟过程中,我使用阿里百炼的时候免费额度用完以后他就断了,重新配置启动以后就要重新模拟了 模拟过程中这个目前没有办法,因为直接使用的是第三方库,开源为了大家部署方便没有使用源码部署,后续应该会开个分支,把专业开发版分出来就可以更改了" + } + ], + "local_coverage": { + "number": 9, + "status": "partial", + "summary": "Refresh/navigation is safer locally because Step 3 reattaches to existing simulation state instead of force-restarting, the history modal exposes a replay-only Step 3 route that loads an existing timeline without accidentally auto-starting a fresh run, the Step 3 panel now makes the same-simulation restart path explicit after quota/API-key failures, Step 2 now surfaces a direct recovery card that reopens the saved Step 3 replay/restart route without making users hunt through history first, and Step 5 now exposes that same direct Step 3 recovery route whenever the interview environment is offline but the prepared simulation still has replayable state. True mid-run checkpoint/resume is still deferred.", + "local_refs": [ + "frontend/src/components/Step2EnvSetup.vue", + "frontend/src/components/Step5Interaction.vue", + "frontend/src/components/step2Recovery.js", + "frontend/src/components/step5Recovery.js", + "frontend/src/components/HistoryDatabase.vue", + "frontend/src/views/SimulationRunView.vue", + "frontend/src/views/SimulationView.vue", + "frontend/src/components/Step3Simulation.vue", + "frontend/src/components/historyPlayback.js", + "frontend/src/components/simulationReplay.js", + "frontend/tests/historyPlayback.test.mjs", + "frontend/tests/step2Recovery.test.mjs", + "frontend/tests/step5Recovery.test.mjs" + ], + "validation": [ + "frontend: npm test", + "frontend: npm run build" + ], + "notes": "The repo still cannot checkpoint and continue from the exact failure point after a provider outage, but the prepared-environment recovery path is now available from history, the Step 2 screen, and the Step 5 offline-interview workspace." + }, + "local_status": "partial", + "local_summary": "Refresh/navigation is safer locally because Step 3 reattaches to existing simulation state instead of force-restarting, the history modal exposes a replay-only Step 3 route that loads an existing timeline without accidentally auto-starting a fresh run, the Step 3 panel now makes the same-simulation restart path explicit after quota/API-key failures, Step 2 now surfaces a direct recovery card that reopens the saved Step 3 replay/restart route without making users hunt through history first, and Step 5 now exposes that same direct Step 3 recovery route whenever the interview environment is offline but the prepared simulation still has replayable state. True mid-run checkpoint/resume is still deferred.", + "triage_status": "partial", + "summary": "Refresh/navigation is safer locally because Step 3 reattaches to existing simulation state instead of force-restarting, the history modal exposes a replay-only Step 3 route that loads an existing timeline without accidentally auto-starting a fresh run, the Step 3 panel now makes the same-simulation restart path explicit after quota/API-key failures, Step 2 now surfaces a direct recovery card that reopens the saved Step 3 replay/restart route without making users hunt through history first, and Step 5 now exposes that same direct Step 3 recovery route whenever the interview environment is offline but the prepared simulation still has replayable state. True mid-run checkpoint/resume is still deferred.", + "coverage_status": "partial", + "coverage_summary": "Refresh/navigation is safer locally because Step 3 reattaches to existing simulation state instead of force-restarting, the history modal exposes a replay-only Step 3 route that loads an existing timeline without accidentally auto-starting a fresh run, the Step 3 panel now makes the same-simulation restart path explicit after quota/API-key failures, Step 2 now surfaces a direct recovery card that reopens the saved Step 3 replay/restart route without making users hunt through history first, and Step 5 now exposes that same direct Step 3 recovery route whenever the interview environment is offline but the prepared simulation still has replayable state. True mid-run checkpoint/resume is still deferred.", + "fork_issue_mirrored": true, + "fork_issue_number": 21, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/21" + }, + { + "number": 69, + "title": "模型对接", + "url": "https://github.com/666ghj/MiroFish/issues/69", + "state": "open", + "created_at": "2026-02-10T07:53:43Z", + "updated_at": "2026-02-10T07:56:20Z", + "closed_at": null, + "labels": [ + "question" + ], + "author": "wjh-w", + "body_excerpt": "我们使用mirofish 的时候可以对接 哪些模型可以帮忙解答一下,比如 # ===== ZEP记忆图谱配置 ===== # 每月免费额度即可支撑简单使用:https://app.getzep.com/ 这个额度用完了还可以使用哪个模型接口,或者说适配哪些模型接口 等等一系列的 都有哪些呢?", + "comment_count": 1, + "recent_comments": [ + { + "author": "dosubot[bot]", + "created_at": "2026-02-10T07:56:20Z", + "updated_at": "2026-02-10T07:56:20Z", + "url": "https://github.com/666ghj/MiroFish/issues/69#issuecomment-3875973660", + "body_excerpt": "<!-- Answer --> MiroFish 支持对接任何兼容 OpenAI SDK 格式的 LLM API,比如 OpenAI、Azure OpenAI、阿里云百炼(qwen-plus)、以及本地自部署的 OpenAI 兼容模型等。你只需要在 .env 配置文件中填写对应的 LLM_API_KEY、LLM_BASE_URL 和 LLM_MODEL_NAME,就可以切换不同的模型接口。例如官方推荐的配置是阿里云百炼的 qwen-plus 模型,也可以直接用 OpenAI…" + } + ], + "local_coverage": { + "number": 69, + "status": "covered", + "summary": "Both READMEs now explicitly document direct OpenAI-compatible usage through either `LLM_*` or `OPENAI_*` environment variables, with examples for Codex/OpenAI-compatible gateways, DashScope, LM Studio, and Ollama.", + "local_refs": [ + "README.md", + "README-EN.md", + ".env.example" + ], + "validation": [ + "documentation review" + ] + }, + "local_status": "covered", + "local_summary": "Both READMEs now explicitly document direct OpenAI-compatible usage through either `LLM_*` or `OPENAI_*` environment variables, with examples for Codex/OpenAI-compatible gateways, DashScope, LM Studio, and Ollama.", + "triage_status": "covered", + "summary": "Both READMEs now explicitly document direct OpenAI-compatible usage through either `LLM_*` or `OPENAI_*` environment variables, with examples for Codex/OpenAI-compatible gateways, DashScope, LM Studio, and Ollama.", + "coverage_status": "covered", + "coverage_summary": "Both READMEs now explicitly document direct OpenAI-compatible usage through either `LLM_*` or `OPENAI_*` environment variables, with examples for Codex/OpenAI-compatible gateways, DashScope, LM Studio, and Ollama.", + "fork_issue_mirrored": true, + "fork_issue_number": 22, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/22" + }, + { + "number": 37, + "title": "模拟环境未运行或已关闭,无法执行Interview: sim_66f01bcf8013。模拟环境可能已关闭,请确保OASIS环境正在运行。", + "url": "https://github.com/666ghj/MiroFish/issues/37", + "state": "open", + "created_at": "2026-01-20T09:10:49Z", + "updated_at": "2026-02-09T04:54:14Z", + "closed_at": null, + "labels": [], + "author": "Ezj-Amon", + "body_excerpt": "这个报错不知道是为啥", + "comment_count": 1, + "recent_comments": [ + { + "author": "maicent", + "created_at": "2026-02-09T04:54:14Z", + "updated_at": "2026-02-09T04:54:14Z", + "url": "https://github.com/666ghj/MiroFish/issues/37#issuecomment-3869291906", + "body_excerpt": "我也遇到了相同问题,请问如何解决" + } + ], + "local_coverage": { + "number": 37, + "status": "covered", + "summary": "Step 5 now preflights simulation environment status and blocks interview requests against closed or unavailable backends before they fail.", + "local_refs": [ + "frontend/src/components/Step5Interaction.vue", + "frontend/src/api/simulation.js" + ], + "validation": [ + "frontend: npm test", + "frontend: npm run build" + ] + }, + "local_status": "covered", + "local_summary": "Step 5 now preflights simulation environment status and blocks interview requests against closed or unavailable backends before they fail.", + "triage_status": "covered", + "summary": "Step 5 now preflights simulation environment status and blocks interview requests against closed or unavailable backends before they fail.", + "coverage_status": "covered", + "coverage_summary": "Step 5 now preflights simulation environment status and blocks interview requests against closed or unavailable backends before they fail.", + "fork_issue_mirrored": true, + "fork_issue_number": 23, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/23" + }, + { + "number": 60, + "title": "Zep 免费计划 429 限流导致图谱构建失败(请求过密)", + "url": "https://github.com/666ghj/MiroFish/issues/60", + "state": "open", + "created_at": "2026-01-24T16:03:41Z", + "updated_at": "2026-01-28T08:36:57Z", + "closed_at": null, + "labels": [], + "author": "Jonah-Wu23", + "body_excerpt": "问题反馈:图谱构建时遇到 Zep 429 限流 现象: 在构建图谱时(调用 Zep Graph API),后台出现 429: [backend] [23:36:22] ERROR: [e1379cb6-ee6b-4989-810b-63ef2e07fa84] 图谱构建失败: headers: {'date': 'Sat, 24 Jan 2026 15:36:21 GMT', 'content-type': 'text/plain; charset=utf-8', 'content-length': '34', 'connection': 'keep-alive', 'cf-ray': '9c309b1a5c42db20-MNL', 'retry-after': '60', 'vary': 'Origin', 'x-content-type-options': 'nosniff', 'x-…", + "comment_count": 4, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-01-24T16:17:07Z", + "updated_at": "2026-01-24T16:17:07Z", + "url": "https://github.com/666ghj/MiroFish/issues/60#issuecomment-3794956637", + "body_excerpt": "感谢反馈" + }, + { + "author": "jstdoit", + "created_at": "2026-01-27T15:11:45Z", + "updated_at": "2026-01-27T15:11:45Z", + "url": "https://github.com/666ghj/MiroFish/issues/60#issuecomment-3805766080", + "body_excerpt": "这个还挺头疼的,导致demo可以,实用上稍微不理想" + }, + { + "author": "magicnight", + "created_at": "2026-01-27T17:34:00Z", + "updated_at": "2026-01-27T17:34:00Z", + "url": "https://github.com/666ghj/MiroFish/issues/60#issuecomment-3806536285", + "body_excerpt": "免费版本只有1000个额度,基本跑一两个实验性的就没了。充值吧,要么考虑自己本地搞。" + } + ], + "local_coverage": { + "number": 60, + "status": "covered", + "summary": "Graph-builder retries now honor bounded Zep `Retry-After` guidance from `429` responses instead of always using the default backoff schedule, reducing premature failures when Zep throttles graph creation or uploads.", + "local_refs": [ + "backend/app/config.py", + "backend/app/services/graph_builder.py", + "backend/tests/test_graph_builder.py" + ], + "validation": [ + "backend/tests/test_graph_builder.py::test_create_graph_respects_retry_after_header", + "backend/tests/test_graph_builder.py::test_create_graph_respects_retry_after_text_hint", + "backend/tests/test_graph_builder.py::test_create_graph_caps_retry_after_delay", + "scripts/test_backend_lite.sh" + ], + "notes": "Mirrored into fork issue tracking on March 11, 2026 after enabling issues on ivanzud/MiroFish." + }, + "local_status": "covered", + "local_summary": "Graph-builder retries now honor bounded Zep `Retry-After` guidance from `429` responses instead of always using the default backoff schedule, reducing premature failures when Zep throttles graph creation or uploads.", + "triage_status": "covered", + "summary": "Graph-builder retries now honor bounded Zep `Retry-After` guidance from `429` responses instead of always using the default backoff schedule, reducing premature failures when Zep throttles graph creation or uploads.", + "coverage_status": "covered", + "coverage_summary": "Graph-builder retries now honor bounded Zep `Retry-After` guidance from `429` responses instead of always using the default backoff schedule, reducing premature failures when Zep throttles graph creation or uploads.", + "fork_issue_mirrored": true, + "fork_issue_number": 24, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/24" + }, + { + "number": 61, + "title": "能否将修改api的功能在前端也实现", + "url": "https://github.com/666ghj/MiroFish/issues/61", + "state": "open", + "created_at": "2026-01-27T13:37:15Z", + "updated_at": "2026-01-28T08:35:08Z", + "closed_at": null, + "labels": [], + "author": "skoa323", + "body_excerpt": "", + "comment_count": 1, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-01-28T08:35:08Z", + "updated_at": "2026-01-28T08:35:08Z", + "url": "https://github.com/666ghj/MiroFish/issues/61#issuecomment-3809795302", + "body_excerpt": "可以详细描述一下吗,不是很懂" + } + ], + "local_coverage": { + "number": 61, + "status": "covered", + "summary": "The frontend now exposes a persisted runtime backend API override panel on the home screen and Step 1 / Step 2 workbench, so users can repoint the UI to another backend without rebuilding.", + "local_refs": [ + "frontend/src/api/baseUrl.js", + "frontend/src/api/index.js", + "frontend/src/components/ApiEndpointControl.vue", + "frontend/src/views/Home.vue", + "frontend/src/views/MainView.vue", + "README.md", + "README-EN.md" + ], + "validation": [ + "frontend/tests/baseUrl.test.mjs", + "frontend: npm run build" + ] + }, + "local_status": "covered", + "local_summary": "The frontend now exposes a persisted runtime backend API override panel on the home screen and Step 1 / Step 2 workbench, so users can repoint the UI to another backend without rebuilding.", + "triage_status": "covered", + "summary": "The frontend now exposes a persisted runtime backend API override panel on the home screen and Step 1 / Step 2 workbench, so users can repoint the UI to another backend without rebuilding.", + "coverage_status": "covered", + "coverage_summary": "The frontend now exposes a persisted runtime backend API override panel on the home screen and Step 1 / Step 2 workbench, so users can repoint the UI to another backend without rebuilding.", + "fork_issue_mirrored": true, + "fork_issue_number": 25, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/25" + }, + { + "number": 62, + "title": "很好的创意,专门注册来提提问题和建议啦", + "url": "https://github.com/666ghj/MiroFish/issues/62", + "state": "open", + "created_at": "2026-01-28T01:39:38Z", + "updated_at": "2026-01-28T08:34:36Z", + "closed_at": null, + "labels": [], + "author": "piaopiaomiaomiao", + "body_excerpt": "1,我也是ollama本地部署,用的是qwen3 A3B那个模型,图谱用的zep,下一步也考虑本地,如果有整合本地包就更好了~~ 2,使用了三天了,跑了4个项目,都不大,zep一共用了200多,小规模实验用还好。 3,4个小项目的共性问题是形成结果后报告助手沟通没问题,但是单人采访不行,报错504. 4,建议自定义轮数可以一开始就填入,给一个一键运行到底的按钮,这样提交完现实种子,选好轮数,点一下就可以静候佳音了,现在中间还要确定两次。 5,up加油!!", + "comment_count": 1, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-01-28T08:34:36Z", + "updated_at": "2026-01-28T08:34:36Z", + "url": "https://github.com/666ghj/MiroFish/issues/62#issuecomment-3809793307", + "body_excerpt": "> 1,我也是ollama本地部署,用的是qwen3 A3B那个模型,图谱用的zep,下一步也考虑本地,如果有整合本地包就更好了~~ 2,使用了三天了,跑了4个项目,都不大,zep一共用了200多,小规模实验用还好。 3,4个小项目的共性问题是形成结果后报告助手沟通没问题,但是单人采访不行,报错504. 4,建议自定义轮数可以一开始就填入,给一个一键运行到底的按钮,这样提交完现实种子,选好轮数,点一下就可以静候佳音了,现在中间还要确定两次。 5,up加油!! 第三个采访,i…" + } + ], + "local_coverage": { + "number": 62, + "status": "partial", + "summary": "The Step 5 timeout complaint now has explicit local mitigations across both UI-driven and report-agent-driven interviews: frontend Step 5 requests derive adaptive timeout budgets from the request window, the UI shows the effective single-agent and current survey-batch budget, the docs/env template expose timeout knobs for slower local models, and backend/app/services/zep_tools.py now honors INTERVIEW_BATCH_TIMEOUT_SECONDS instead of forcing a fixed 180-second timeout for live report-agent batch interviews. The broader issue's remaining workflow requests are now tracked separately in beads as `mirofish-as6`.", + "local_refs": [ + ".beads/issues.jsonl", + "frontend/src/api/timeout.js", + "frontend/src/components/Step5Interaction.vue", + "frontend/src/components/step5Profiles.js", + "backend/app/services/zep_tools.py", + "backend/tests/test_zep_tools_i18n.py", + ".env.example", + "README.md", + "README-EN.md" + ], + "validation": [ + "frontend: npm test", + "frontend: npm run build", + "backend: uv run pytest -q tests/test_zep_tools_i18n.py" + ] + }, + "local_status": "partial", + "local_summary": "The Step 5 timeout complaint now has explicit local mitigations across both UI-driven and report-agent-driven interviews: frontend Step 5 requests derive adaptive timeout budgets from the request window, the UI shows the effective single-agent and current survey-batch budget, the docs/env template expose timeout knobs for slower local models, and backend/app/services/zep_tools.py now honors INTERVIEW_BATCH_TIMEOUT_SECONDS instead of forcing a fixed 180-second timeout for live report-agent batch interviews. The broader issue's remaining workflow requests are now tracked separately in beads as `mirofish-as6`.", + "triage_status": "partial", + "summary": "The Step 5 timeout complaint now has explicit local mitigations across both UI-driven and report-agent-driven interviews: frontend Step 5 requests derive adaptive timeout budgets from the request window, the UI shows the effective single-agent and current survey-batch budget, the docs/env template expose timeout knobs for slower local models, and backend/app/services/zep_tools.py now honors INTERVIEW_BATCH_TIMEOUT_SECONDS instead of forcing a fixed 180-second timeout for live report-agent batch interviews. The broader issue's remaining workflow requests are now tracked separately in beads as `mirofish-as6`.", + "coverage_status": "partial", + "coverage_summary": "The Step 5 timeout complaint now has explicit local mitigations across both UI-driven and report-agent-driven interviews: frontend Step 5 requests derive adaptive timeout budgets from the request window, the UI shows the effective single-agent and current survey-batch budget, the docs/env template expose timeout knobs for slower local models, and backend/app/services/zep_tools.py now honors INTERVIEW_BATCH_TIMEOUT_SECONDS instead of forcing a fixed 180-second timeout for live report-agent batch interviews. The broader issue's remaining workflow requests are now tracked separately in beads as `mirofish-as6`.", + "fork_issue_mirrored": true, + "fork_issue_number": 26, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/26" + }, + { + "number": 58, + "title": "使用ollama加载的本地大模型启动引擎时经常会遇到超时问题", + "url": "https://github.com/666ghj/MiroFish/issues/58", + "state": "open", + "created_at": "2026-01-24T10:47:13Z", + "updated_at": "2026-01-24T16:18:14Z", + "closed_at": null, + "labels": [], + "author": "ThomasWang071001", + "body_excerpt": "如果本地大模型响应时间太长(超过300000ms)则会出现error并且即使后台监控大模型完成了输出前端也无法正常显示 有没有办法把这个超时限制关掉", + "comment_count": 3, + "recent_comments": [ + { + "author": "ThomasWang071001", + "created_at": "2026-01-24T12:30:18Z", + "updated_at": "2026-01-24T12:30:18Z", + "url": "https://github.com/666ghj/MiroFish/issues/58#issuecomment-3794560081", + "body_excerpt": "backend会不断试图进行本体生成 [backend] * Serving Flask app 'app' [backend] * Debug mode: on [backend] WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead. [backend] * Running on all a…" + }, + { + "author": "puji4810", + "created_at": "2026-01-24T12:38:17Z", + "updated_at": "2026-01-24T12:38:17Z", + "url": "https://github.com/666ghj/MiroFish/issues/58#issuecomment-3794571191", + "body_excerpt": "源码部署自己修改一下咯。本地ai又慢又弱,不推荐。你这响应时间超过300000ms了还是用api吧" + }, + { + "author": "666ghj", + "created_at": "2026-01-24T16:18:13Z", + "updated_at": "2026-01-24T16:18:13Z", + "url": "https://github.com/666ghj/MiroFish/issues/58#issuecomment-3794961360", + "body_excerpt": "这个让ai帮忙定位一下,一个超时参数的数值问题" + } + ], + "local_coverage": { + "number": 58, + "status": "covered", + "summary": "Step 5 interviews now derive timeout overrides from the configured frontend request window, and the backend/docs expose dedicated interview timeout settings for slower local models.", + "local_refs": [ + "frontend/src/api/timeout.js", + "frontend/src/components/Step5Interaction.vue", + ".env.example" + ], + "validation": [ + "frontend: npm test", + "frontend: npm run build" + ] + }, + "local_status": "covered", + "local_summary": "Step 5 interviews now derive timeout overrides from the configured frontend request window, and the backend/docs expose dedicated interview timeout settings for slower local models.", + "triage_status": "covered", + "summary": "Step 5 interviews now derive timeout overrides from the configured frontend request window, and the backend/docs expose dedicated interview timeout settings for slower local models.", + "coverage_status": "covered", + "coverage_summary": "Step 5 interviews now derive timeout overrides from the configured frontend request window, and the backend/docs expose dedicated interview timeout settings for slower local models.", + "fork_issue_mirrored": true, + "fork_issue_number": 27, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/27" + }, + { + "number": 46, + "title": "npm安装依赖和配置提示project.license` as a TOML table is deprecated", + "url": "https://github.com/666ghj/MiroFish/issues/46", + "state": "open", + "created_at": "2026-01-21T12:26:05Z", + "updated_at": "2026-01-23T10:34:52Z", + "closed_at": null, + "labels": [], + "author": "Vamco2022", + "body_excerpt": "npm run setup:all提示 Please use a simple string containing a SPDX expression for `project.license`. You can also use `project.license-files`. (Both options available on setuptools>=77.0.0). By 2026-Feb-18, you need to update your project and remove deprecated calls or your builds will no longer be supported. See https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#license for deta…", + "comment_count": 2, + "recent_comments": [ + { + "author": "wjcwqc", + "created_at": "2026-01-22T09:09:47Z", + "updated_at": "2026-01-22T09:09:47Z", + "url": "https://github.com/666ghj/MiroFish/issues/46#issuecomment-3783323757", + "body_excerpt": "python3.12 venv 可以借解决 tiktoken没有提供高版本python的wheel" + }, + { + "author": "Vamco2022", + "created_at": "2026-01-23T10:34:52Z", + "updated_at": "2026-01-23T10:34:52Z", + "url": "https://github.com/666ghj/MiroFish/issues/46#issuecomment-3789585469", + "body_excerpt": "现在没事了,给自己气笑了,uv自带python的版本问题" + } + ], + "local_coverage": { + "number": 46, + "status": "covered", + "summary": "The setuptools `project.license` warning path is gone because the repo now uses an SPDX license string.", + "local_refs": [ + "backend/pyproject.toml" + ], + "validation": [ + "backend: uv run pytest -q" + ] + }, + "local_status": "covered", + "local_summary": "The setuptools `project.license` warning path is gone because the repo now uses an SPDX license string.", + "triage_status": "covered", + "summary": "The setuptools `project.license` warning path is gone because the repo now uses an SPDX license string.", + "coverage_status": "covered", + "coverage_summary": "The setuptools `project.license` warning path is gone because the repo now uses an SPDX license string.", + "fork_issue_mirrored": true, + "fork_issue_number": 28, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/28" + }, + { + "number": 55, + "title": "做了一个基于本地neo4j的版本", + "url": "https://github.com/666ghj/MiroFish/issues/55", + "state": "open", + "created_at": "2026-01-23T03:08:03Z", + "updated_at": "2026-01-23T06:27:04Z", + "closed_at": null, + "labels": [], + "author": "xumengke2025-sys", + "body_excerpt": "感谢博主的思路和分享!因为我的zep限额一下就用完了还经常返回超时报错,所以做了个基于本地部署neo4j的版本,neo4j的设置也体现在env文件里,优点是终于稳定了也可以无限生成了,缺点是比zep要慢;可以联系博主直接把项目包发你吗", + "comment_count": 1, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-01-23T06:27:04Z", + "updated_at": "2026-01-23T06:27:04Z", + "url": "https://github.com/666ghj/MiroFish/issues/55#issuecomment-3788527267", + "body_excerpt": "厉害👍,网络上确实有一些本地化的实现方式,如果你愿意的话,可以把你的方法提pr出来,我们会把所有本地化的方案都放在首页供大家参考的。" + } + ], + "local_coverage": { + "number": 55, + "status": "tracked", + "summary": "Tracked under beads issue `mirofish-8eg`: local graph backend alternatives such as Neo4j support need a deliberate backend-abstraction design pass instead of a blind merge of third-party code.", + "local_refs": [ + ".beads/issues.jsonl", + "docs/upstream-triage.md" + ], + "validation": [ + "tracking only" + ] + }, + "local_status": "tracked", + "local_summary": "Tracked under beads issue `mirofish-8eg`: local graph backend alternatives such as Neo4j support need a deliberate backend-abstraction design pass instead of a blind merge of third-party code.", + "triage_status": "tracked", + "summary": "Tracked under beads issue `mirofish-8eg`: local graph backend alternatives such as Neo4j support need a deliberate backend-abstraction design pass instead of a blind merge of third-party code.", + "coverage_status": "tracked", + "coverage_summary": "Tracked under beads issue `mirofish-8eg`: local graph backend alternatives such as Neo4j support need a deliberate backend-abstraction design pass instead of a blind merge of third-party code.", + "fork_issue_mirrored": true, + "fork_issue_number": 29, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/29" + }, + { + "number": 54, + "title": "太喜欢你这个项目了!给了我巨大的帮助!", + "url": "https://github.com/666ghj/MiroFish/issues/54", + "state": "open", + "created_at": "2026-01-23T02:39:53Z", + "updated_at": "2026-01-23T02:39:53Z", + "closed_at": null, + "labels": [], + "author": "zb2947244682", + "body_excerpt": "太喜欢你这个项目了!给了我巨大的帮助! 我正好也在研究这个方向。", + "comment_count": 0, + "recent_comments": [], + "local_coverage": { + "number": 54, + "status": "no_action", + "summary": "Upstream issue #54 is positive feedback rather than a reproducible bug or scoped feature request, so there is no local implementation task attached to it.", + "local_refs": [ + "docs/upstream-open-state.json" + ], + "validation": [ + "triage only" + ] + }, + "local_status": "no_action", + "local_summary": "Upstream issue #54 is positive feedback rather than a reproducible bug or scoped feature request, so there is no local implementation task attached to it.", + "triage_status": "no_action", + "summary": "Upstream issue #54 is positive feedback rather than a reproducible bug or scoped feature request, so there is no local implementation task attached to it.", + "coverage_status": "no_action", + "coverage_summary": "Upstream issue #54 is positive feedback rather than a reproducible bug or scoped feature request, so there is no local implementation task attached to it.", + "fork_issue_mirrored": true, + "fork_issue_number": 30, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/30" + }, + { + "number": 52, + "title": "API最大长度超过导致程序奔溃", + "url": "https://github.com/666ghj/MiroFish/issues/52", + "state": "open", + "created_at": "2026-01-22T13:25:33Z", + "updated_at": "2026-01-22T13:25:33Z", + "closed_at": null, + "labels": [], + "author": "ngyygm", + "body_excerpt": "在调用自己部署的API时,模型最大长度如果小于18000可能会报错。 个人使用的时候,显示系统传输的token数在16384,我设定的是16000最大长度,然后我改成320000就能正常跑了。", + "comment_count": 0, + "recent_comments": [], + "local_coverage": { + "number": 52, + "status": "covered", + "summary": "Report generation now trims oversized message history, retries after context-length failures, and exposes configurable `LLM_MAX_TOKENS` for smaller-context models.", + "local_refs": [ + "backend/app/services/report_agent.py", + "backend/app/utils/llm_client.py", + "backend/tests/test_report_agent.py", + "backend/tests/test_llm_client.py" + ], + "validation": [ + "backend/tests/test_report_agent.py", + "backend/tests/test_llm_client.py" + ] + }, + "local_status": "covered", + "local_summary": "Report generation now trims oversized message history, retries after context-length failures, and exposes configurable `LLM_MAX_TOKENS` for smaller-context models.", + "triage_status": "covered", + "summary": "Report generation now trims oversized message history, retries after context-length failures, and exposes configurable `LLM_MAX_TOKENS` for smaller-context models.", + "coverage_status": "covered", + "coverage_summary": "Report generation now trims oversized message history, retries after context-length failures, and exposes configurable `LLM_MAX_TOKENS` for smaller-context models.", + "fork_issue_mirrored": true, + "fork_issue_number": 31, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/31" + }, + { + "number": 24, + "title": "ERROR: 报告生成失败: 'NoneType' object is not subscriptable", + "url": "https://github.com/666ghj/MiroFish/issues/24", + "state": "open", + "created_at": "2026-01-14T15:53:08Z", + "updated_at": "2026-01-21T13:28:41Z", + "closed_at": null, + "labels": [], + "author": "Joe-rq", + "body_excerpt": "ERROR: 报告生成失败: 'NoneType' object is not subscriptable LLM返回了一个异常长的响应(4856字符),内容是重复的文本\"意识流捕获、语义锚点、灵感固化。探究AI如何处理\"非线性跳跃\"。\"重复了上百次 LLM返回了空响应(response_length: 0),这导致后续代码尝试访问None对象时出错 问题根源: LLM API返回了空响应或异常响应,导致代码在处理时出现 'NoneType' object is not subscriptable 错误。", + "comment_count": 2, + "recent_comments": [ + { + "author": "moonhalf-nostar", + "created_at": "2026-01-19T09:55:33Z", + "updated_at": "2026-01-19T09:55:33Z", + "url": "https://github.com/666ghj/MiroFish/issues/24#issuecomment-3767439957", + "body_excerpt": "可以把详细的日志贴出来,方便后续修复 bug" + }, + { + "author": "xyz50270", + "created_at": "2026-01-21T13:28:41Z", + "updated_at": "2026-01-21T13:28:41Z", + "url": "https://github.com/666ghj/MiroFish/issues/24#issuecomment-3778142354", + "body_excerpt": "碰上一样的情况了 ``` [21:11:54] INFO: 从 reddit_profiles.json 加载了 34 个人设 [21:11:54] INFO: 加载到 34 个Agent人设 [21:12:09] INFO: 选择了 5 个Agent进行采访: [1, 2, 3, 30, 6] [21:12:22] INFO: 生成了 5 个采访问题 [21:12:22] INFO: 调用批量采访API(双平台): 5 个Agent [21:12:37] INFO: 采访…" + } + ], + "local_coverage": { + "number": 24, + "status": "covered", + "summary": "Report generation now tolerates empty LLM section responses by retrying, then falling back to a per-section placeholder instead of crashing the whole report flow with a `NoneType` subscript error.", + "local_refs": [ + "backend/app/services/report_agent.py", + "backend/tests/test_report_agent.py" + ], + "validation": [ + "backend/tests/test_report_agent.py::test_generate_report_survives_empty_llm_section_responses" + ] + }, + "local_status": "covered", + "local_summary": "Report generation now tolerates empty LLM section responses by retrying, then falling back to a per-section placeholder instead of crashing the whole report flow with a `NoneType` subscript error.", + "triage_status": "covered", + "summary": "Report generation now tolerates empty LLM section responses by retrying, then falling back to a per-section placeholder instead of crashing the whole report flow with a `NoneType` subscript error.", + "coverage_status": "covered", + "coverage_summary": "Report generation now tolerates empty LLM section responses by retrying, then falling back to a per-section placeholder instead of crashing the whole report flow with a `NoneType` subscript error.", + "fork_issue_mirrored": true, + "fork_issue_number": 32, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/32" + }, + { + "number": 45, + "title": "与世界中任意个体对话功能和发送调查问卷到世界中 两个功能报错 IPC 响应 failed", + "url": "https://github.com/666ghj/MiroFish/issues/45", + "state": "open", + "created_at": "2026-01-21T06:46:41Z", + "updated_at": "2026-01-21T07:11:18Z", + "closed_at": null, + "labels": [], + "author": "LucasXu666666", + "body_excerpt": "前端报错信息: <img width=\"1453\" height=\"588\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/ed8cdb71-c7b8-463c-b287-e69ae82ae474\" /> 后端报错信息: [backend] [14:42:38] INFO: 发送批量Interview命令: simulation_id=sim_4ba82ee5afeb, count=1, platform=None [backend] [14:42:38] INFO: 发送IPC命令: batch_interview, command_id=fc8bf264-c5c5-43ab-bbe4-c93f99d55cb3 [backend] [14:42:39] INFO: 收到IPC响应: command_id=fc8b…", + "comment_count": 1, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-01-21T07:11:18Z", + "updated_at": "2026-01-21T07:11:18Z", + "url": "https://github.com/666ghj/MiroFish/issues/45#issuecomment-3776523496", + "body_excerpt": "interview_agents 方法依赖于模拟环境处于运行状态才能运行,也就是需要从第三步跑完以后继续执行才行" + } + ], + "local_coverage": { + "number": 45, + "status": "covered", + "summary": "Step 5 now preserves platform metadata and targets Reddit/Twitter interviews against the matching backend instead of assuming a Reddit-only profile list.", + "local_refs": [ + "frontend/src/components/Step5Interaction.vue", + "frontend/src/components/step5Profiles.js", + "frontend/tests/step5Profiles.test.mjs" + ], + "validation": [ + "frontend/tests/step5Profiles.test.mjs", + "frontend: npm run build" + ] + }, + "local_status": "covered", + "local_summary": "Step 5 now preserves platform metadata and targets Reddit/Twitter interviews against the matching backend instead of assuming a Reddit-only profile list.", + "triage_status": "covered", + "summary": "Step 5 now preserves platform metadata and targets Reddit/Twitter interviews against the matching backend instead of assuming a Reddit-only profile list.", + "coverage_status": "covered", + "coverage_summary": "Step 5 now preserves platform metadata and targets Reddit/Twitter interviews against the matching backend instead of assuming a Reddit-only profile list.", + "fork_issue_mirrored": true, + "fork_issue_number": 33, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/33" + }, + { + "number": 43, + "title": "在使用时调用IPC超时", + "url": "https://github.com/666ghj/MiroFish/issues/43", + "state": "open", + "created_at": "2026-01-21T02:23:35Z", + "updated_at": "2026-01-21T07:10:12Z", + "closed_at": null, + "labels": [], + "author": "dbplayer-git", + "body_excerpt": "<img width=\"1446\" height=\"543\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/5d101d3b-6dbc-43c4-8c68-2493f9badad3\" />", + "comment_count": 1, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-01-21T07:10:12Z", + "updated_at": "2026-01-21T07:10:12Z", + "url": "https://github.com/666ghj/MiroFish/issues/43#issuecomment-3776520523", + "body_excerpt": "是不是模拟世界太大了,这样采访接口就会好久" + } + ], + "local_coverage": { + "number": 43, + "status": "covered", + "summary": "Step 5 now rewrites timeout and environment-closed failures into actionable guidance, and the backend/frontend timeout knobs give slow local models an explicit supported path.", + "local_refs": [ + "frontend/src/components/Step5Interaction.vue", + "frontend/src/api/timeout.js", + ".env.example" + ], + "validation": [ + "frontend: npm test", + "frontend: npm run build" + ] + }, + "local_status": "covered", + "local_summary": "Step 5 now rewrites timeout and environment-closed failures into actionable guidance, and the backend/frontend timeout knobs give slow local models an explicit supported path.", + "triage_status": "covered", + "summary": "Step 5 now rewrites timeout and environment-closed failures into actionable guidance, and the backend/frontend timeout knobs give slow local models an explicit supported path.", + "coverage_status": "covered", + "coverage_summary": "Step 5 now rewrites timeout and environment-closed failures into actionable guidance, and the backend/frontend timeout knobs give slow local models an explicit supported path.", + "fork_issue_mirrored": true, + "fork_issue_number": 34, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/34" + }, + { + "number": 19, + "title": "建议初次使用不用太大的pdf", + "url": "https://github.com/666ghj/MiroFish/issues/19", + "state": "open", + "created_at": "2026-01-14T02:45:19Z", + "updated_at": "2026-01-15T05:57:37Z", + "closed_at": null, + "labels": [], + "author": "paperplane123", + "body_excerpt": "没跑完,我的zep一个月额度就没有了,下个月再试吧", + "comment_count": 1, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-01-15T05:57:37Z", + "updated_at": "2026-01-15T05:57:37Z", + "url": "https://github.com/666ghj/MiroFish/issues/19#issuecomment-3753021224", + "body_excerpt": "对的,还是建议一万字以内,30轮左右模拟" + } + ], + "local_coverage": { + "number": 19, + "status": "covered", + "summary": "The home upload flow and quick-start docs now explicitly recommend starting with smaller source material (roughly 10k words) and about 30 simulation rounds on a first run, reducing avoidable quota burn and onboarding confusion.", + "local_refs": [ + "frontend/src/views/Home.vue", + "frontend/src/i18n/locales/zh.js", + "frontend/src/i18n/locales/en.js", + "README.md", + "README-EN.md" + ], + "validation": [ + "frontend: npm run build" + ] + }, + "local_status": "covered", + "local_summary": "The home upload flow and quick-start docs now explicitly recommend starting with smaller source material (roughly 10k words) and about 30 simulation rounds on a first run, reducing avoidable quota burn and onboarding confusion.", + "triage_status": "covered", + "summary": "The home upload flow and quick-start docs now explicitly recommend starting with smaller source material (roughly 10k words) and about 30 simulation rounds on a first run, reducing avoidable quota burn and onboarding confusion.", + "coverage_status": "covered", + "coverage_summary": "The home upload flow and quick-start docs now explicitly recommend starting with smaller source material (roughly 10k words) and about 30 simulation rounds on a first run, reducing avoidable quota burn and onboarding confusion.", + "fork_issue_mirrored": true, + "fork_issue_number": 35, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/35" + }, + { + "number": 21, + "title": "部署在群辉服务器上,docker里启动了服务,然后用过反向代理可以访问前端了,如果已经开始模拟了,刷新网页或者关掉再打开会发生什么", + "url": "https://github.com/666ghj/MiroFish/issues/21", + "state": "open", + "created_at": "2026-01-14T08:33:53Z", + "updated_at": "2026-01-14T08:33:53Z", + "closed_at": null, + "labels": [], + "author": "usernametooshort", + "body_excerpt": "做准备等了半天,不敢试,但是我觉得docker在服务器端,我关掉网页再打开应该也还是可以恢复吧 <img width=\"1728\" height=\"965\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/f132121a-ee66-4623-880b-3ea8782c2265\" />", + "comment_count": 0, + "recent_comments": [], + "local_coverage": { + "number": 21, + "status": "covered", + "summary": "The docs/UI now clarify that refreshing the browser does not stop backend jobs, persisted runs remain reopenable from history, and Step 3/5 still require a live runtime session.", + "local_refs": [ + "frontend/src/components/HistoryDatabase.vue", + "README.md", + "README-EN.md" + ], + "validation": [ + "frontend: npm run build" + ] + }, + "local_status": "covered", + "local_summary": "The docs/UI now clarify that refreshing the browser does not stop backend jobs, persisted runs remain reopenable from history, and Step 3/5 still require a live runtime session.", + "triage_status": "covered", + "summary": "The docs/UI now clarify that refreshing the browser does not stop backend jobs, persisted runs remain reopenable from history, and Step 3/5 still require a live runtime session.", + "coverage_status": "covered", + "coverage_summary": "The docs/UI now clarify that refreshing the browser does not stop backend jobs, persisted runs remain reopenable from history, and Step 3/5 still require a live runtime session.", + "fork_issue_mirrored": true, + "fork_issue_number": 36, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/36" + }, + { + "number": 17, + "title": "多次仿真 + 元报告共识,提升预测结果的可信度", + "url": "https://github.com/666ghj/MiroFish/issues/17", + "state": "open", + "created_at": "2026-01-06T09:07:51Z", + "updated_at": "2026-01-06T09:07:51Z", + "closed_at": null, + "labels": [], + "author": "tt-a1i", + "body_excerpt": "## 背景 我觉得这个项目做的很棒,很有意思,这两天跑了几轮,发现单次仿真的结果可能比较受模型偏好的影响?——同样的输入材料,换个模型,结论可能会有差异。这让我在想:有没有办法让预测结果更稳定、更可信? ## 想法 核心思路是:**同一份材料跑多次仿真,然后在报告阶段做\"共识提取 + 分歧分析\"**。 具体来说: 1. **固定 Step1/2**:图谱构建和环境配置只跑一次,作为多次仿真的共享基础 2. **Step3 跑 2-3 次**:每次可以用不同的随机种子,或者稍微调整一些参数(比如 temperature) 3. **Step4 做元报告**: - 把\"多次都出现的结论\"作为**共识**(可信度高) - 把\"只在某一次出现的走向\"列为**分歧情景**,并尝试分析触发条件 4. **不同步骤用不同模型**: - 结构化抽取(本体/实体/配置 JSON)用格式稳定、指令遵从强的模…", + "comment_count": 0, + "recent_comments": [], + "local_coverage": { + "number": 17, + "status": "tracked", + "summary": "Tracked locally in beads as `mirofish-77h`: a future multi-run consensus workflow should reuse a shared Step 1/2 setup, orchestrate repeated Step 3 runs, and synthesize a consensus/divergence report in Step 4.", + "local_refs": [ + ".beads/issues.jsonl", + "docs/upstream-triage.md" + ], + "validation": [ + "tracking only" + ] + }, + "local_status": "tracked", + "local_summary": "Tracked locally in beads as `mirofish-77h`: a future multi-run consensus workflow should reuse a shared Step 1/2 setup, orchestrate repeated Step 3 runs, and synthesize a consensus/divergence report in Step 4.", + "triage_status": "tracked", + "summary": "Tracked locally in beads as `mirofish-77h`: a future multi-run consensus workflow should reuse a shared Step 1/2 setup, orchestrate repeated Step 3 runs, and synthesize a consensus/divergence report in Step 4.", + "coverage_status": "tracked", + "coverage_summary": "Tracked locally in beads as `mirofish-77h`: a future multi-run consensus workflow should reuse a shared Step 1/2 setup, orchestrate repeated Step 3 runs, and synthesize a consensus/divergence report in Step 4.", + "fork_issue_mirrored": true, + "fork_issue_number": 37, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/37" + }, + { + "number": 14, + "title": "Frontend doesn't show error when simulation fails", + "url": "https://github.com/666ghj/MiroFish/issues/14", + "state": "open", + "created_at": "2026-01-05T11:43:44Z", + "updated_at": "2026-01-06T06:15:22Z", + "closed_at": null, + "labels": [], + "author": "tt-a1i", + "body_excerpt": "## What happened When a simulation fails (process crashes, missing deps, etc.), the frontend just sits there showing \"running\" status with no error message. The backend logs the error correctly and returns `runner_status: 'failed'` with an error message, but the UI never picks it up. Ran into this while testing locally - took me a while to figure out why nothing was happening. ## Steps to reprodu…", + "comment_count": 1, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-01-06T06:15:22Z", + "updated_at": "2026-01-06T06:15:22Z", + "url": "https://github.com/666ghj/MiroFish/issues/14#issuecomment-3713269669", + "body_excerpt": "I'll investigate this, and you're also welcome to submit a PR first." + } + ], + "local_coverage": { + "number": 14, + "status": "covered", + "summary": "Step 3 now stops polling and surfaces backend failure text when the simulation runner reports `failed`, so the UI no longer hangs indefinitely.", + "local_refs": [ + "frontend/src/components/Step3Simulation.vue", + "frontend/tests/errors.test.mjs" + ], + "validation": [ + "frontend: npm test", + "frontend: npm run build" + ] + }, + "local_status": "covered", + "local_summary": "Step 3 now stops polling and surfaces backend failure text when the simulation runner reports `failed`, so the UI no longer hangs indefinitely.", + "triage_status": "covered", + "summary": "Step 3 now stops polling and surfaces backend failure text when the simulation runner reports `failed`, so the UI no longer hangs indefinitely.", + "coverage_status": "covered", + "coverage_summary": "Step 3 now stops polling and surfaces backend failure text when the simulation runner reports `failed`, so the UI no longer hangs indefinitely.", + "fork_issue_mirrored": true, + "fork_issue_number": 38, + "fork_issue_url": "https://github.com/ivanzud/MiroFish/issues/38" + } + ], + "pull_requests": [ + { + "number": 144, + "title": "feat(kg): add dual-mode knowledge graph support", + "url": "https://github.com/666ghj/MiroFish/pull/144", + "state": "open", + "created_at": "2026-03-11T14:39:08Z", + "updated_at": "2026-03-12T03:22:22Z", + "closed_at": null, + "merged_at": null, + "head": "feat/local-knowledge-graph", + "head_ref_name": "feat/local-knowledge-graph", + "head_sha": "c3953e455227f5bc1ead6c89bb9c13aa49812d3b", + "head_repo": "huamingjie0815/MiroFish", + "head_clone_url": "https://github.com/huamingjie0815/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "Memory Layer", + "size:XXL" + ], + "author": "huamingjie0815", + "body_excerpt": "## Summary - Add kg_adapter for dual-mode knowledge graph (cloud/local) - Support switching between Zep Cloud and local Graphiti + Neo4j - Improve entity extraction and report agent robustness - Add test_kg_adapter.py with unit tests ## Test plan - [ ] Test cloud mode with Zep Cloud - [ ] Test local mode with Graphiti + Neo4j - [ ] Run unit tests 🤖 Generated with [Claude Code](https://claude.com/…", + "comment_count": 1, + "review_comment_count": 0, + "recent_comments": [ + { + "author": "huamingjie0815", + "created_at": "2026-03-12T03:22:22Z", + "updated_at": "2026-03-12T03:22:22Z", + "url": "https://github.com/666ghj/MiroFish/pull/144#issuecomment-4043640028", + "body_excerpt": "支持图谱的local 和cloud 双模式,local 是基于graphiti 改造,需要自己配置embedding模型 ,同时该提交增加一些功能优化,包括删除推演记录、导出报告、重新生成报告等功能,调整report_agent 的tool_call 的格式,从json改为xml 。" + } + ], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-144", + "mirror_ref": "origin/mirror/upstream-pr-144", + "local_coverage": { + "number": 144, + "status": "tracked", + "summary": "Tracked under beads issue `mirofish-8eg`: the dual-mode local knowledge graph branch is directionally aligned with the non-Zep backend request, but it is not safe to cherry-pick wholesale because it adds a large new adapter plus dependency stack on top of an older tree without current graph/simulation regression coverage.", + "local_refs": [ + ".beads/issues.jsonl", + "origin/mirror/upstream-pr-144" + ], + "validation": [ + "triage diff review", + "git diff --stat HEAD...upstream/pr-144" + ] + }, + "local_status": "tracked", + "local_summary": "Tracked under beads issue `mirofish-8eg`: the dual-mode local knowledge graph branch is directionally aligned with the non-Zep backend request, but it is not safe to cherry-pick wholesale because it adds a large new adapter plus dependency stack on top of an older tree without current graph/simulation regression coverage.", + "triage_status": "tracked", + "summary": "Tracked under beads issue `mirofish-8eg`: the dual-mode local knowledge graph branch is directionally aligned with the non-Zep backend request, but it is not safe to cherry-pick wholesale because it adds a large new adapter plus dependency stack on top of an older tree without current graph/simulation regression coverage.", + "coverage_status": "tracked", + "coverage_summary": "Tracked under beads issue `mirofish-8eg`: the dual-mode local knowledge graph branch is directionally aligned with the non-Zep backend request, but it is not safe to cherry-pick wholesale because it adds a large new adapter plus dependency stack on top of an older tree without current graph/simulation regression coverage.", + "local_review": { + "status": "tracked", + "summary": "Tracked under beads issue `mirofish-8eg`: the dual-mode local knowledge graph branch is directionally aligned with the non-Zep backend request, but it is not safe to cherry-pick wholesale because it adds a large new adapter plus dependency stack on top of an older tree without current graph/simulation regression coverage.", + "local_refs": [ + ".beads/issues.jsonl", + "origin/mirror/upstream-pr-144" + ], + "validation": [ + "triage diff review", + "git diff --stat HEAD...upstream/pr-144" + ], + "notes": null + } + }, + { + "number": 152, + "title": "feat(report): Zep 命名修复与导出 Markdown 功能", + "url": "https://github.com/666ghj/MiroFish/pull/152", + "state": "open", + "created_at": "2026-03-11T18:09:38Z", + "updated_at": "2026-03-12T02:15:37Z", + "closed_at": null, + "merged_at": null, + "head": "support-pascal-and-snake-case", + "head_ref_name": "support-pascal-and-snake-case", + "head_sha": "d04ce413711e33219a8a124da6d3e8e0102f8803", + "head_repo": "sx-tane/MiroFish", + "head_clone_url": "https://github.com/sx-tane/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "size:XS" + ], + "author": "sx-tane", + "body_excerpt": "## 概述 本 PR 包含以下改进: 1. **Zep 命名修复**:修复了 Zep API 实体/关系命名的格式校验错误(支持 PascalCase 和 snake_case)。 2. **新增功能**:报告生成步骤支持导出为 Markdown 格式,并采用了正式的 PDF 风格排版。 ## 修改详情 ### 后端 (Backend) - 在 `report_agent.py` 中改进了 `ReportManager.assemble_full_report` 方法,新增了包含 ID、模拟场景和时间戳的正式页眉。 - 添加了章节分隔符,显著提升了导出的 Markdown 文件的可读性。 ### 前端 (Frontend) - 在 `Step4Report.vue` 的报告页眉部分新增了“导出 MD”按钮。 - 在 `src/api/report.js` 中实现了 `downloadRe…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-152", + "mirror_ref": "origin/mirror/upstream-pr-152", + "local_coverage": { + "number": 152, + "status": "landed", + "summary": "Landed locally as repo-native subsets instead of a wholesale cherry-pick: ontology generation and graph build submission now normalize type names to Zep-compatible PascalCase / SCREAMING_SNAKE_CASE conventions, and TaskManager state now persists to disk so graph/report task status survives backend restarts.", + "local_refs": [ + "backend/app/services/ontology_generator.py", + "backend/tests/test_ontology_generator.py", + "backend/app/services/graph_builder.py", + "backend/tests/test_graph_builder.py", + "backend/app/models/task.py", + "backend/tests/test_task_manager.py", + "origin/mirror/upstream-pr-152", + ".beads/issues.jsonl" + ], + "validation": [ + "cd backend && uv run pytest -q tests/test_ontology_generator.py", + "python3 -m compileall backend/app/services/ontology_generator.py backend/tests/test_ontology_generator.py", + "cd backend && uv run pytest -q tests/test_graph_builder.py", + "python3 -m compileall backend/app/services/graph_builder.py", + "uv run --project backend pytest -q backend/tests/test_task_manager.py backend/tests/test_backend_localized_errors.py", + "python3 -m compileall backend/app/models/task.py backend/tests/test_task_manager.py" + ] + }, + "local_status": "landed", + "local_summary": "Landed locally as repo-native subsets instead of a wholesale cherry-pick: ontology generation and graph build submission now normalize type names to Zep-compatible PascalCase / SCREAMING_SNAKE_CASE conventions, and TaskManager state now persists to disk so graph/report task status survives backend restarts.", + "triage_status": "landed", + "summary": "Landed locally as repo-native subsets instead of a wholesale cherry-pick: ontology generation and graph build submission now normalize type names to Zep-compatible PascalCase / SCREAMING_SNAKE_CASE conventions, and TaskManager state now persists to disk so graph/report task status survives backend restarts.", + "coverage_status": "landed", + "coverage_summary": "Landed locally as repo-native subsets instead of a wholesale cherry-pick: ontology generation and graph build submission now normalize type names to Zep-compatible PascalCase / SCREAMING_SNAKE_CASE conventions, and TaskManager state now persists to disk so graph/report task status survives backend restarts.", + "local_review": { + "status": "landed", + "summary": "Landed locally as repo-native subsets instead of a wholesale cherry-pick: ontology generation and graph build submission now normalize type names to Zep-compatible PascalCase / SCREAMING_SNAKE_CASE conventions, and TaskManager state now persists to disk so graph/report task status survives backend restarts.", + "local_refs": [ + "backend/app/services/ontology_generator.py", + "backend/tests/test_ontology_generator.py", + "backend/app/services/graph_builder.py", + "backend/tests/test_graph_builder.py", + "backend/app/models/task.py", + "backend/tests/test_task_manager.py", + "origin/mirror/upstream-pr-152", + ".beads/issues.jsonl" + ], + "validation": [ + "cd backend && uv run pytest -q tests/test_ontology_generator.py", + "python3 -m compileall backend/app/services/ontology_generator.py backend/tests/test_ontology_generator.py", + "cd backend && uv run pytest -q tests/test_graph_builder.py", + "python3 -m compileall backend/app/services/graph_builder.py", + "uv run --project backend pytest -q backend/tests/test_task_manager.py backend/tests/test_backend_localized_errors.py", + "python3 -m compileall backend/app/models/task.py backend/tests/test_task_manager.py" + ], + "notes": null + } + }, + { + "number": 155, + "title": "chore: backend, frontend, i18n (en/zh), and Docker updates", + "url": "https://github.com/666ghj/MiroFish/pull/155", + "state": "open", + "created_at": "2026-03-12T00:47:20Z", + "updated_at": "2026-03-12T00:56:30Z", + "closed_at": null, + "merged_at": null, + "head": "english-trans", + "head_ref_name": "english-trans", + "head_sha": "dac678d45f3a3f7b167c6b22f22bf5d21dda1cab", + "head_repo": "kaeli-byte/MiroFish", + "head_clone_url": "https://github.com/kaeli-byte/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "size:XXL" + ], + "author": "kaeli-byte", + "body_excerpt": "Made-with: Cursor", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-155", + "mirror_ref": "origin/mirror/upstream-pr-155", + "local_coverage": { + "number": 155, + "status": "tracked", + "summary": "Tracked under beads issue `mirofish-2ul1`: the combined backend/frontend/i18n/docker sweep is mirrored into `origin/mirror/upstream-pr-155`, but it rewrites 59 files on an older tree and is not safe to cherry-pick wholesale on top of the current repo-native OpenAI-compat, tests, docs, and partial i18n work.", + "local_refs": [ + ".beads/issues.jsonl", + "origin/mirror/upstream-pr-155", + "docs/upstream-triage.md", + "scripts/sync_upstream_github.py", + "tests/test_sync_upstream_github.py" + ], + "validation": [ + "git diff --stat upstream/main...origin/mirror/upstream-pr-155", + "python3 -m unittest tests.test_sync_upstream_github" + ] + }, + "local_status": "tracked", + "local_summary": "Tracked under beads issue `mirofish-2ul1`: the combined backend/frontend/i18n/docker sweep is mirrored into `origin/mirror/upstream-pr-155`, but it rewrites 59 files on an older tree and is not safe to cherry-pick wholesale on top of the current repo-native OpenAI-compat, tests, docs, and partial i18n work.", + "triage_status": "tracked", + "summary": "Tracked under beads issue `mirofish-2ul1`: the combined backend/frontend/i18n/docker sweep is mirrored into `origin/mirror/upstream-pr-155`, but it rewrites 59 files on an older tree and is not safe to cherry-pick wholesale on top of the current repo-native OpenAI-compat, tests, docs, and partial i18n work.", + "coverage_status": "tracked", + "coverage_summary": "Tracked under beads issue `mirofish-2ul1`: the combined backend/frontend/i18n/docker sweep is mirrored into `origin/mirror/upstream-pr-155`, but it rewrites 59 files on an older tree and is not safe to cherry-pick wholesale on top of the current repo-native OpenAI-compat, tests, docs, and partial i18n work.", + "local_review": { + "status": "tracked", + "summary": "Tracked under beads issue `mirofish-2ul1`: the combined backend/frontend/i18n/docker sweep is mirrored into `origin/mirror/upstream-pr-155`, but it rewrites 59 files on an older tree and is not safe to cherry-pick wholesale on top of the current repo-native OpenAI-compat, tests, docs, and partial i18n work.", + "local_refs": [ + ".beads/issues.jsonl", + "origin/mirror/upstream-pr-155", + "docs/upstream-triage.md", + "scripts/sync_upstream_github.py", + "tests/test_sync_upstream_github.py" + ], + "validation": [ + "git diff --stat upstream/main...origin/mirror/upstream-pr-155", + "python3 -m unittest tests.test_sync_upstream_github" + ], + "notes": null + } + }, + { + "number": 151, + "title": "Fix silent data loss when platform defaults to reddit for Twitter-only simulations", + "url": "https://github.com/666ghj/MiroFish/pull/151", + "state": "open", + "created_at": "2026-03-11T17:49:51Z", + "updated_at": "2026-03-11T17:49:59Z", + "closed_at": null, + "merged_at": null, + "head": "fix/platform-default-reddit-silent-failure", + "head_ref_name": "fix/platform-default-reddit-silent-failure", + "head_sha": "18ba979c8da4cbbd25c2ce9615d5ad65f242b718", + "head_repo": "karesansui-u/MiroFish", + "head_clone_url": "https://github.com/karesansui-u/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "size:M" + ], + "author": "karesansui-u", + "body_excerpt": "## Summary - API retrieval endpoints (`/profiles`, `/profiles/realtime`, `/posts`, `/comments`) hardcoded `'reddit'` as the default platform - When a Twitter-only simulation was run (`enable_reddit=false`), these APIs silently returned empty results because they looked for `reddit_simulation.db` / `reddit_profiles.json` which did not exist - Frontend also hardcoded `'reddit'` in Vue components an…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-151", + "mirror_ref": "origin/mirror/upstream-pr-151", + "local_coverage": { + "number": 151, + "status": "landed", + "summary": "Landed locally before the upstream PR opened: Twitter-only simulations now infer the active platform instead of silently defaulting to Reddit in retrieval APIs and profile loading, matching the intent of upstream PR #151.", + "local_refs": [ + "backend/app/api/simulation.py", + "backend/app/services/simulation_manager.py", + "backend/tests/test_simulation_service_i18n.py", + "backend/tests/test_simulation_api_i18n.py", + "origin/mirror/upstream-pr-151" + ], + "validation": [ + "cd backend && uv run pytest -q tests/test_simulation_service_i18n.py tests/test_simulation_api_i18n.py", + "python3 -m compileall backend/app/services/simulation_manager.py backend/app/api/simulation.py" + ] + }, + "local_status": "landed", + "local_summary": "Landed locally before the upstream PR opened: Twitter-only simulations now infer the active platform instead of silently defaulting to Reddit in retrieval APIs and profile loading, matching the intent of upstream PR #151.", + "triage_status": "landed", + "summary": "Landed locally before the upstream PR opened: Twitter-only simulations now infer the active platform instead of silently defaulting to Reddit in retrieval APIs and profile loading, matching the intent of upstream PR #151.", + "coverage_status": "landed", + "coverage_summary": "Landed locally before the upstream PR opened: Twitter-only simulations now infer the active platform instead of silently defaulting to Reddit in retrieval APIs and profile loading, matching the intent of upstream PR #151.", + "local_review": { + "status": "landed", + "summary": "Landed locally before the upstream PR opened: Twitter-only simulations now infer the active platform instead of silently defaulting to Reddit in retrieval APIs and profile loading, matching the intent of upstream PR #151.", + "local_refs": [ + "backend/app/api/simulation.py", + "backend/app/services/simulation_manager.py", + "backend/tests/test_simulation_service_i18n.py", + "backend/tests/test_simulation_api_i18n.py", + "origin/mirror/upstream-pr-151" + ], + "validation": [ + "cd backend && uv run pytest -q tests/test_simulation_service_i18n.py tests/test_simulation_api_i18n.py", + "python3 -m compileall backend/app/services/simulation_manager.py backend/app/api/simulation.py" + ], + "notes": null + } + }, + { + "number": 147, + "title": "feat: Russian localization (Русская локализация)", + "url": "https://github.com/666ghj/MiroFish/pull/147", + "state": "open", + "created_at": "2026-03-11T16:17:18Z", + "updated_at": "2026-03-11T16:19:27Z", + "closed_at": null, + "merged_at": null, + "head": "russian-localization", + "head_ref_name": "russian-localization", + "head_sha": "cdfece4e116a03d2631e1a70a41e0d86fc4473e4", + "head_repo": "notageek88/MiroFish", + "head_clone_url": "https://github.com/notageek88/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "documentation", + "size:XXL" + ], + "author": "notageek88", + "body_excerpt": "## 🇷🇺 Russian Localization This PR adds a complete Russian translation of MiroFish: ### Changes: - **15 Vue components** — all UI labels, buttons, placeholders, error messages, and tooltips translated from Chinese to Russian - **README-RU.md** — full Russian documentation with quick start guide - Translation files are in `frontend-ru/src/` (ready to merge into `frontend/src/` when approved) - LLM…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-147", + "mirror_ref": "origin/mirror/upstream-pr-147", + "local_coverage": { + "number": 147, + "status": "partial", + "summary": "Safe subset landed locally: added a repo-native `README-RU.md` plus language cross-links, but the full branch is not safe to cherry-pick because it replaces large frontend/backend sections and drops current local tooling, tests, and upstream-triage assets.", + "local_refs": [ + "README.md", + "README-EN.md", + "README-JA.md", + "README-KO.md", + "README-RU.md", + "origin/mirror/upstream-pr-147" + ], + "validation": [ + "documentation review", + "git diff --stat HEAD..mirror/upstream-pr-147" + ] + }, + "local_status": "partial", + "local_summary": "Safe subset landed locally: added a repo-native `README-RU.md` plus language cross-links, but the full branch is not safe to cherry-pick because it replaces large frontend/backend sections and drops current local tooling, tests, and upstream-triage assets.", + "triage_status": "partial", + "summary": "Safe subset landed locally: added a repo-native `README-RU.md` plus language cross-links, but the full branch is not safe to cherry-pick because it replaces large frontend/backend sections and drops current local tooling, tests, and upstream-triage assets.", + "coverage_status": "partial", + "coverage_summary": "Safe subset landed locally: added a repo-native `README-RU.md` plus language cross-links, but the full branch is not safe to cherry-pick because it replaces large frontend/backend sections and drops current local tooling, tests, and upstream-triage assets.", + "local_review": { + "status": "partial", + "summary": "Safe subset landed locally: added a repo-native `README-RU.md` plus language cross-links, but the full branch is not safe to cherry-pick because it replaces large frontend/backend sections and drops current local tooling, tests, and upstream-triage assets.", + "local_refs": [ + "README.md", + "README-EN.md", + "README-JA.md", + "README-KO.md", + "README-RU.md", + "origin/mirror/upstream-pr-147" + ], + "validation": [ + "documentation review", + "git diff --stat HEAD..mirror/upstream-pr-147" + ], + "notes": null + } + }, + { + "number": 141, + "title": "feat: add entity deduplication after graph building", + "url": "https://github.com/666ghj/MiroFish/pull/141", + "state": "open", + "created_at": "2026-03-11T13:38:51Z", + "updated_at": "2026-03-11T14:48:14Z", + "closed_at": null, + "merged_at": null, + "head": "feature/entity-deduplication", + "head_ref_name": "feature/entity-deduplication", + "head_sha": "a728540a258804476a7058e21023bf94dfb48026", + "head_repo": "Stayfoool/MiroFish", + "head_clone_url": "https://github.com/Stayfoool/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "enhancement", + "size:XL" + ], + "author": "Stayfoool", + "body_excerpt": "Hi @666ghj I noticed that during graph building, Zep sometimes creates duplicate entity nodes for the same real-world entity (e.g. \"特朗普\" and \"美国总统特朗普\" appear as separate nodes). This affects the accuracy of the knowledge graph. This PR adds an automatic entity deduplication step after graph building, using name similarity pre-filtering + type compatibility check + LLM confirmation to identify and…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-141", + "mirror_ref": "origin/mirror/upstream-pr-141", + "local_coverage": { + "number": 141, + "status": "not_safe", + "summary": "Not safe to cherry-pick: the entity-deduplication branch rewinds large portions of the current tree (tooling, tests, i18n, OpenAI-compatible docs/config) while adding a large graph mutation feature, so it needs a repo-native reimplementation with targeted regression coverage instead of a blind merge.", + "local_refs": [ + "origin/mirror/upstream-pr-141", + ".beads/issues.jsonl" + ], + "validation": [ + "triage diff review", + "git diff --stat HEAD..upstream/pr/141" + ] + }, + "local_status": "not_safe", + "local_summary": "Not safe to cherry-pick: the entity-deduplication branch rewinds large portions of the current tree (tooling, tests, i18n, OpenAI-compatible docs/config) while adding a large graph mutation feature, so it needs a repo-native reimplementation with targeted regression coverage instead of a blind merge.", + "triage_status": "not_safe", + "summary": "Not safe to cherry-pick: the entity-deduplication branch rewinds large portions of the current tree (tooling, tests, i18n, OpenAI-compatible docs/config) while adding a large graph mutation feature, so it needs a repo-native reimplementation with targeted regression coverage instead of a blind merge.", + "coverage_status": "not_safe", + "coverage_summary": "Not safe to cherry-pick: the entity-deduplication branch rewinds large portions of the current tree (tooling, tests, i18n, OpenAI-compatible docs/config) while adding a large graph mutation feature, so it needs a repo-native reimplementation with targeted regression coverage instead of a blind merge.", + "local_review": { + "status": "not_safe", + "summary": "Not safe to cherry-pick: the entity-deduplication branch rewinds large portions of the current tree (tooling, tests, i18n, OpenAI-compatible docs/config) while adding a large graph mutation feature, so it needs a repo-native reimplementation with targeted regression coverage instead of a blind merge.", + "local_refs": [ + "origin/mirror/upstream-pr-141", + ".beads/issues.jsonl" + ], + "validation": [ + "triage diff review", + "git diff --stat HEAD..upstream/pr/141" + ], + "notes": null + } + }, + { + "number": 143, + "title": "docs: fix README alt text URL encoding", + "url": "https://github.com/666ghj/MiroFish/pull/143", + "state": "open", + "created_at": "2026-03-11T14:37:42Z", + "updated_at": "2026-03-11T14:39:04Z", + "closed_at": null, + "merged_at": null, + "head": "docs/urlEncoding", + "head_ref_name": "docs/urlEncoding", + "head_sha": "ecf6a84a3b471583cb5a436f9ef83c72e24c65b2", + "head_repo": "fishwww-ww/MiroFish", + "head_clone_url": "https://github.com/fishwww-ww/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "documentation", + "size:XS" + ], + "author": "fishwww-ww", + "body_excerpt": "## Summary Fix the Shanda image alt text in README.md by changing 666ghj%2MiroFish to 666ghj%2FMiroFish. ## Details 666ghj%2MiroFish is not a valid URL-encoded representation, so it cannot be decoded correctly. Using 666ghj%2FMiroFish correctly encodes the slash and can be properly decoded to 666ghj/ MiroFish. ## Impact Documentation-only change. No code or runtime behavior is affected.", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-143", + "mirror_ref": "origin/mirror/upstream-pr-143", + "local_coverage": { + "number": 143, + "status": "landed", + "summary": "Landed locally as a repo-native docs cleanup: the Shanda logo alt text now uses the correct URL-encoded `666ghj%2FMiroFish` slug across all README variants, not just the primary Chinese README.", + "local_refs": [ + "README.md", + "README-EN.md", + "README-JA.md", + "README-KO.md", + "origin/mirror/upstream-pr-143" + ], + "validation": [ + "documentation review", + "rg -n \"666ghj%2MiroFish|666ghj%2FMiroFish\" README.md README-EN.md README-JA.md README-KO.md" + ] + }, + "local_status": "landed", + "local_summary": "Landed locally as a repo-native docs cleanup: the Shanda logo alt text now uses the correct URL-encoded `666ghj%2FMiroFish` slug across all README variants, not just the primary Chinese README.", + "triage_status": "landed", + "summary": "Landed locally as a repo-native docs cleanup: the Shanda logo alt text now uses the correct URL-encoded `666ghj%2FMiroFish` slug across all README variants, not just the primary Chinese README.", + "coverage_status": "landed", + "coverage_summary": "Landed locally as a repo-native docs cleanup: the Shanda logo alt text now uses the correct URL-encoded `666ghj%2FMiroFish` slug across all README variants, not just the primary Chinese README.", + "local_review": { + "status": "landed", + "summary": "Landed locally as a repo-native docs cleanup: the Shanda logo alt text now uses the correct URL-encoded `666ghj%2FMiroFish` slug across all README variants, not just the primary Chinese README.", + "local_refs": [ + "README.md", + "README-EN.md", + "README-JA.md", + "README-KO.md", + "origin/mirror/upstream-pr-143" + ], + "validation": [ + "documentation review", + "rg -n \"666ghj%2MiroFish|666ghj%2FMiroFish\" README.md README-EN.md README-JA.md README-KO.md" + ], + "notes": null + } + }, + { + "number": 105, + "title": "fix: security improvements and error handling fixes", + "url": "https://github.com/666ghj/MiroFish/pull/105", + "state": "open", + "created_at": "2026-03-09T11:57:58Z", + "updated_at": "2026-03-11T05:48:53Z", + "closed_at": null, + "merged_at": null, + "head": "fix/security-improvements", + "head_ref_name": "fix/security-improvements", + "head_sha": "a33adf3e1f92e456f1134befd8745e0bad09034a", + "head_repo": "hobostay/MiroFish", + "head_clone_url": "https://github.com/hobostay/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "documentation", + "size:L" + ], + "author": "hobostay", + "body_excerpt": "## 问题概述 这个PR修复了项目中发现的多个安全问题和代码质量问题。 ## 安全修复 1. **硬编码的SECRET_KEY** - `backend/app/config.py` - 之前:使用硬编码的`'mirofish-secret-key'`作为默认值 - 现在:如果未设置环境变量,会生成随机密钥并发出警告 2. **DEBUG模式默认为True** - `backend/app/config.py` - 之前:`DEBUG`默认为`True` - 现在:`DEBUG`默认为`False`,生产环境更安全 3. **CORS配置允许所有来源** - `backend/app/__init__.py` - 之前:`CORS(app, resources={r\"/api/*\": {\"origins\": \"*\"}})` - 现在:通过环境变量`CORS_ALLOWED_ORIGINS…", + "comment_count": 2, + "review_comment_count": 0, + "recent_comments": [ + { + "author": "JasonOA888", + "created_at": "2026-03-09T17:00:24Z", + "updated_at": "2026-03-09T17:00:24Z", + "url": "https://github.com/666ghj/MiroFish/pull/105#issuecomment-4025290222", + "body_excerpt": "## 代码审查反馈 优秀的PR!这些安全修复非常关键,特别是生产环境部署时。 ### 几个建议: 1. **SECRET_KEY随机生成** - 建议添加日志记录生成的key,方便调试但不要泄露到错误响应中 2. **CORS配置** - 考虑添加`CORS_ALLOW_METHODS`和`CORS_ALLOW_HEADERS`配置,提供更细粒度的控制 3. **error_handler.py** - 建议添加自定义异常类型,让API可以抛出特定错误而不是通用Except…" + }, + { + "author": "hobostay", + "created_at": "2026-03-11T05:48:53Z", + "updated_at": "2026-03-11T05:48:53Z", + "url": "https://github.com/666ghj/MiroFish/pull/105#issuecomment-4036667034", + "body_excerpt": "@JasonOA888 感谢您的审查反馈!我已经根据您的建议完成了所有修改: **✅ 1. SECRET_KEY随机生成** - 使用 `secrets.token_hex(32)` 生成随机密钥 - 添加日志记录生成的key(方便调试) - 密钥只记录到日志,不会泄露到API错误响应中 **✅ 2. CORS细粒度配置** - 新增 `CORS_ALLOW_METHODS` 环境变量(默认:GET,POST,PUT,DELETE,OPTIONS) - 新增 `CORS_A…" + } + ], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-105", + "mirror_ref": "origin/mirror/upstream-pr-105", + "local_coverage": { + "number": 105, + "status": "landed", + "summary": "Landed locally: backend security/config hardening now includes env-driven CORS controls with a localhost-only default allowlist, `DEBUG=False` by default, and generated fallback `SECRET_KEY` behavior." + }, + "local_status": "landed", + "local_summary": "Landed locally: backend security/config hardening now includes env-driven CORS controls with a localhost-only default allowlist, `DEBUG=False` by default, and generated fallback `SECRET_KEY` behavior.", + "triage_status": "landed", + "summary": "Landed locally: backend security/config hardening now includes env-driven CORS controls with a localhost-only default allowlist, `DEBUG=False` by default, and generated fallback `SECRET_KEY` behavior.", + "coverage_status": "landed", + "coverage_summary": "Landed locally: backend security/config hardening now includes env-driven CORS controls with a localhost-only default allowlist, `DEBUG=False` by default, and generated fallback `SECRET_KEY` behavior.", + "local_review": { + "status": "landed", + "summary": "Landed locally: backend security/config hardening now includes env-driven CORS controls with a localhost-only default allowlist, `DEBUG=False` by default, and generated fallback `SECRET_KEY` behavior.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 132, + "title": "docs:add simple system architecture part for README-EN.md & README.md", + "url": "https://github.com/666ghj/MiroFish/pull/132", + "state": "open", + "created_at": "2026-03-11T05:45:53Z", + "updated_at": "2026-03-11T05:47:00Z", + "closed_at": null, + "merged_at": null, + "head": "docs/add-sys-architecture-part", + "head_ref_name": "docs/add-sys-architecture-part", + "head_sha": "a9d1c4839a23e6027b49d89af77728ab32cda167", + "head_repo": "Noblegasesgoo/MiroFish", + "head_clone_url": "https://github.com/Noblegasesgoo/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "documentation", + "size:L" + ], + "author": "Noblegasesgoo", + "body_excerpt": "## PR Title docs(readme): simplify system architecture section to Layer Breakdown + Project Code Structure Tree only ## Summary This PR simplifies the **System Architecture** section in both Chinese and English README files by keeping only two high-signal sections: - **Layer Breakdown** - **Project Code Structure Tree** The previously added overall architecture diagram and related agent-intro blo…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-132", + "mirror_ref": "origin/mirror/upstream-pr-132", + "local_coverage": { + "number": 132, + "status": "landed", + "summary": "Landed locally: README architecture overview." + }, + "local_status": "landed", + "local_summary": "Landed locally: README architecture overview.", + "triage_status": "landed", + "summary": "Landed locally: README architecture overview.", + "coverage_status": "landed", + "coverage_summary": "Landed locally: README architecture overview.", + "local_review": { + "status": "landed", + "summary": "Landed locally: README architecture overview.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 131, + "title": "feat(graph_builder): add retry mechanism for Zep Cloud connection failures", + "url": "https://github.com/666ghj/MiroFish/pull/131", + "state": "open", + "created_at": "2026-03-11T05:42:07Z", + "updated_at": "2026-03-11T05:43:10Z", + "closed_at": null, + "merged_at": null, + "head": "feat/zep-retry-mechanism", + "head_ref_name": "feat/zep-retry-mechanism", + "head_sha": "264d2c8757c1b9e5aead7c873ffd29f4add8ad4a", + "head_repo": "EuanTop/MiroFish", + "head_clone_url": "https://github.com/EuanTop/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "enhancement", + "size:L" + ], + "author": "EuanTop", + "body_excerpt": "## Description Adds automatic retry mechanism to handle transient network errors when connecting to Zep Cloud API. This prevents graph build failures caused by temporary connection issues such as \"Connection reset by peer\" (errno 54). The retry logic uses exponential backoff (2s, 4s, 6s) and provides detailed progress feedback to users. ## Changes - Added retry logic (max 3 attempts) to `create_g…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-131", + "mirror_ref": "origin/mirror/upstream-pr-131", + "local_coverage": { + "number": 131, + "status": "landed", + "summary": "Safe subset landed locally: transient Zep failures now retry with bounded backoff." + }, + "local_status": "landed", + "local_summary": "Safe subset landed locally: transient Zep failures now retry with bounded backoff.", + "triage_status": "landed", + "summary": "Safe subset landed locally: transient Zep failures now retry with bounded backoff.", + "coverage_status": "landed", + "coverage_summary": "Safe subset landed locally: transient Zep failures now retry with bounded backoff.", + "local_review": { + "status": "landed", + "summary": "Safe subset landed locally: transient Zep failures now retry with bounded backoff.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 130, + "title": "docs: 添加贡献指南文档", + "url": "https://github.com/666ghj/MiroFish/pull/130", + "state": "open", + "created_at": "2026-03-11T04:24:56Z", + "updated_at": "2026-03-11T04:26:00Z", + "closed_at": null, + "merged_at": null, + "head": "docs/add-pr-guide", + "head_ref_name": "docs/add-pr-guide", + "head_sha": "32d0571fd7bd59fae341a98cc63f7991409d8a9b", + "head_repo": "M-Tlinqinming/MiroFish", + "head_clone_url": "https://github.com/M-Tlinqinming/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "documentation", + "size:M" + ], + "author": "M-Tlinqinming", + "body_excerpt": "", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-130", + "mirror_ref": "origin/mirror/upstream-pr-130", + "local_coverage": { + "number": 130, + "status": "landed", + "summary": "Landed locally: `CONTRIBUTING.md`." + }, + "local_status": "landed", + "local_summary": "Landed locally: `CONTRIBUTING.md`.", + "triage_status": "landed", + "summary": "Landed locally: `CONTRIBUTING.md`.", + "coverage_status": "landed", + "coverage_summary": "Landed locally: `CONTRIBUTING.md`.", + "local_review": { + "status": "landed", + "summary": "Landed locally: `CONTRIBUTING.md`.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 129, + "title": "fix(report_agent): handle API token overflow crash with context lengt…", + "url": "https://github.com/666ghj/MiroFish/pull/129", + "state": "open", + "created_at": "2026-03-11T02:55:33Z", + "updated_at": "2026-03-11T02:56:38Z", + "closed_at": null, + "merged_at": null, + "head": "fix/fix-priority-issues-mNNjT", + "head_ref_name": "fix/fix-priority-issues-mNNjT", + "head_sha": "20c830af12cecd6fa6f03e8c0aca98b40dd8c858", + "head_repo": "ai-x-builder/MiroFish", + "head_clone_url": "https://github.com/ai-x-builder/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "LLM API", + "size:M" + ], + "author": "ai-x-builder", + "body_excerpt": "Add error handling in LLMClient for context_length_exceeded errors with automatic message trimming and retry (fixes https://github.com/666ghj/MiroFish/issues/52) Add configurable LLM_MAX_TOKENS env variable (default 4096) so users with different models can set appropriate limits Add message history pruning in report agent ReACT loop to prevent unbounded context growth that causes token overflow I…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-129", + "mirror_ref": "origin/mirror/upstream-pr-129", + "local_coverage": { + "number": 129, + "status": "landed", + "summary": "Safe subset landed locally for context-length retry, configurable `LLM_MAX_TOKENS`, and report-agent history pruning." + }, + "local_status": "landed", + "local_summary": "Safe subset landed locally for context-length retry, configurable `LLM_MAX_TOKENS`, and report-agent history pruning.", + "triage_status": "landed", + "summary": "Safe subset landed locally for context-length retry, configurable `LLM_MAX_TOKENS`, and report-agent history pruning.", + "coverage_status": "landed", + "coverage_summary": "Safe subset landed locally for context-length retry, configurable `LLM_MAX_TOKENS`, and report-agent history pruning.", + "local_review": { + "status": "landed", + "summary": "Safe subset landed locally for context-length retry, configurable `LLM_MAX_TOKENS`, and report-agent history pruning.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 126, + "title": "feat: Add custom exceptions and enhanced config validation", + "url": "https://github.com/666ghj/MiroFish/pull/126", + "state": "open", + "created_at": "2026-03-10T22:12:22Z", + "updated_at": "2026-03-10T22:13:37Z", + "closed_at": null, + "merged_at": null, + "head": "feature/custom-exceptions-and-config-validation", + "head_ref_name": "feature/custom-exceptions-and-config-validation", + "head_sha": "e639bd629ef5ebc260459287a863566453d07317", + "head_repo": "ZaviQ7/MiroFish", + "head_clone_url": "https://github.com/ZaviQ7/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "enhancement", + "size:XXL" + ], + "author": "ZaviQ7", + "body_excerpt": "## Summary This PR improves the robustness of the MiroFish backend by implementing two key architectural improvements: ### 1. Custom Exception Hierarchy - Created a `MiroFishError` base class with error codes, severity levels, and HTTP status codes. - Added domain-specific exceptions for Configuration, Graphs, Simulations, and External APIs to replace generic Exception catches. ### 2. Enhanced Co…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-126", + "mirror_ref": "origin/mirror/upstream-pr-126", + "local_coverage": { + "number": 126, + "status": "landed", + "summary": "Safe subset landed locally for structured config validation and non-sensitive config summaries." + }, + "local_status": "landed", + "local_summary": "Safe subset landed locally for structured config validation and non-sensitive config summaries.", + "triage_status": "landed", + "summary": "Safe subset landed locally for structured config validation and non-sensitive config summaries.", + "coverage_status": "landed", + "coverage_summary": "Safe subset landed locally for structured config validation and non-sensitive config summaries.", + "local_review": { + "status": "landed", + "summary": "Safe subset landed locally for structured config validation and non-sensitive config summaries.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 125, + "title": "fix: improve new-project network error diagnostics", + "url": "https://github.com/666ghj/MiroFish/pull/125", + "state": "open", + "created_at": "2026-03-10T21:50:43Z", + "updated_at": "2026-03-10T21:51:51Z", + "closed_at": null, + "merged_at": null, + "head": "fix/issue-121", + "head_ref_name": "fix/issue-121", + "head_sha": "3cd206cfc291f7b7311d27cdd0382f24d582bf99", + "head_repo": "SergioChan/MiroFish", + "head_clone_url": "https://github.com/SergioChan/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "enhancement", + "size:S" + ], + "author": "SergioChan", + "body_excerpt": "## Summary Improve frontend error feedback when creating a new project so users can quickly diagnose \"Network Error\" and timeout failures instead of seeing a generic message. ## Changes - Added `formatProjectInitError` in `frontend/src/views/Process.vue` - Distinguish timeout errors and provide actionable hint (reduce file size / check model speed) - Distinguish network errors and show configured…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-125", + "mirror_ref": "origin/mirror/upstream-pr-125", + "local_coverage": { + "number": 125, + "status": "landed", + "summary": "Landed locally: improved new-project network error diagnostics in the frontend." + }, + "local_status": "landed", + "local_summary": "Landed locally: improved new-project network error diagnostics in the frontend.", + "triage_status": "landed", + "summary": "Landed locally: improved new-project network error diagnostics in the frontend.", + "coverage_status": "landed", + "coverage_summary": "Landed locally: improved new-project network error diagnostics in the frontend.", + "local_review": { + "status": "landed", + "summary": "Landed locally: improved new-project network error diagnostics in the frontend.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 124, + "title": "fix: robust JSON extraction for mixed LLM responses", + "url": "https://github.com/666ghj/MiroFish/pull/124", + "state": "open", + "created_at": "2026-03-10T21:50:31Z", + "updated_at": "2026-03-10T21:51:46Z", + "closed_at": null, + "merged_at": null, + "head": "fix/issue-64", + "head_ref_name": "fix/issue-64", + "head_sha": "08d5313024500371140c713f72bedcd730a99914", + "head_repo": "SergioChan/MiroFish", + "head_clone_url": "https://github.com/SergioChan/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "LLM API", + "size:M" + ], + "author": "SergioChan", + "body_excerpt": "## SummarynnHarden backend JSON parsing for LLM responses so mixed outputs (markdown fences, pre/post text) are handled more robustly, reducing 500 errors reported during ontology generation.nn## Changesnn- Updated `LLMClient.chat()` to remove `<think ...>...</think>` tags case-insensitivelyn- Added `LLMClient._extract_json_payload()` to normalize and extract JSON from noisy model responsesn- Upd…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-124", + "mirror_ref": "origin/mirror/upstream-pr-124", + "local_coverage": { + "number": 124, + "status": "landed", + "summary": "Landed locally: robust JSON extraction for mixed LLM responses." + }, + "local_status": "landed", + "local_summary": "Landed locally: robust JSON extraction for mixed LLM responses.", + "triage_status": "landed", + "summary": "Landed locally: robust JSON extraction for mixed LLM responses.", + "coverage_status": "landed", + "coverage_summary": "Landed locally: robust JSON extraction for mixed LLM responses.", + "local_review": { + "status": "landed", + "summary": "Landed locally: robust JSON extraction for mixed LLM responses.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 122, + "title": "fix(llm_client): remove response_format json_object for local LLM compatibility", + "url": "https://github.com/666ghj/MiroFish/pull/122", + "state": "open", + "created_at": "2026-03-10T18:20:49Z", + "updated_at": "2026-03-10T18:33:48Z", + "closed_at": null, + "merged_at": null, + "head": "fix/lm-studio-json-object-compat", + "head_ref_name": "fix/lm-studio-json-object-compat", + "head_sha": "481cc009a392549017c08f27b9500f5ab41415be", + "head_repo": "ImL1s/MiroFish", + "head_clone_url": "https://github.com/ImL1s/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "LLM API", + "size:XS" + ], + "author": "ImL1s", + "body_excerpt": "## Problem `chat_json()` uses `response_format={\"type\": \"json_object\"}`, but LM Studio and Ollama do not support this parameter (only `json_schema` or `text`), causing API calls to fail when using local LLMs. Related references: - LM Studio: https://github.com/lmstudio-ai/lmstudio-bug-tracker/issues/534 - Similar to issue #110 (API call failures) ## Solution Remove `response_format` from `chat_js…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-122", + "mirror_ref": "origin/mirror/upstream-pr-122", + "local_coverage": { + "number": 122, + "status": "landed", + "summary": "Landed locally: removed `response_format={type: json_object}` from `chat_json()` for LM Studio and Ollama compatibility." + }, + "local_status": "landed", + "local_summary": "Landed locally: removed `response_format={type: json_object}` from `chat_json()` for LM Studio and Ollama compatibility.", + "triage_status": "landed", + "summary": "Landed locally: removed `response_format={type: json_object}` from `chat_json()` for LM Studio and Ollama compatibility.", + "coverage_status": "landed", + "coverage_summary": "Landed locally: removed `response_format={type: json_object}` from `chat_json()` for LM Studio and Ollama compatibility.", + "local_review": { + "status": "landed", + "summary": "Landed locally: removed `response_format={type: json_object}` from `chat_json()` for LM Studio and Ollama compatibility.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 119, + "title": "feat: add an option to switch to english language", + "url": "https://github.com/666ghj/MiroFish/pull/119", + "state": "open", + "created_at": "2026-03-10T15:05:40Z", + "updated_at": "2026-03-10T18:14:33Z", + "closed_at": null, + "merged_at": null, + "head": "language-option", + "head_ref_name": "language-option", + "head_sha": "cb868d30438bced7e10fe0efd4d30db0e6c32e3e", + "head_repo": "Pratiyankkumar/MiroFish", + "head_clone_url": "https://github.com/Pratiyankkumar/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "enhancement", + "size:XXL" + ], + "author": "Pratiyankkumar", + "body_excerpt": "Right now the content of the website is mostly in Chinese , Added an button to switch between Chinese and english language . [`Demo Video`](https://drive.google.com/file/d/15VYI0J1SoDRf27Zvprm1P-D4MO8hA7yE/view?usp=sharing)", + "comment_count": 2, + "review_comment_count": 0, + "recent_comments": [ + { + "author": "Pratiyankkumar", + "created_at": "2026-03-10T18:14:16Z", + "updated_at": "2026-03-10T18:14:16Z", + "url": "https://github.com/666ghj/MiroFish/pull/119#issuecomment-4033470105", + "body_excerpt": "<img width=\"1470\" height=\"835\" alt=\"Screenshot 2026-03-10 at 11 42 33 PM\" src=\"https://github.com/user-attachments/assets/1483f8c1-da70-4d76-8058-6a5752204564\" /> **PR summary (last two prompts):** 1. **Error message i18n** – Added `errors…" + }, + { + "author": "Pratiyankkumar", + "created_at": "2026-03-10T18:14:33Z", + "updated_at": "2026-03-10T18:14:33Z", + "url": "https://github.com/666ghj/MiroFish/pull/119#issuecomment-4033471655", + "body_excerpt": "@hzr1937 please review" + } + ], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-119", + "mirror_ref": "origin/mirror/upstream-pr-119", + "local_coverage": { + "number": 119, + "status": "partial", + "summary": "Safe subset landed locally for persisted EN/ZH UI chrome, locale headers, browser-language-aware first-run locale bootstrap, deterministic Step 2/3 workflow logs, MainView workflow/build logs, locale-aware report and InsightForge sub-query prompt scaffolding, English-localized ReportAgent tool descriptions and parameter help, bilingual Step 4 Insight/Panorama parsing, deterministic Step 5 interview helper fallbacks/prompts, English-mode Oasis profile prompt empty-state fallbacks, English-localized GraphBuilderService worker progress strings, and English fallback labels in `zep_tools` Panorama/InsightForge report output, but broader backend/runtime localization remains a follow-up instead of a blind merge.", + "local_refs": [ + "frontend/src/views/MainView.vue", + "frontend/src/views/mainViewLogMessages.js", + "backend/app/services/graph_builder.py", + "frontend/src/components/Step4Report.vue", + "frontend/src/components/reportParsers.js", + "backend/app/services/report_agent.py", + "backend/app/services/zep_tools.py", + "backend/app/services/oasis_profile_generator.py", + "frontend/src/i18n/index.js", + "frontend/src/i18n/locales/en.js", + "frontend/src/i18n/locales/zh.js", + "backend/tests/test_graph_builder.py", + "backend/tests/test_report_agent.py", + "backend/tests/test_openai_compat_services.py", + "frontend/tests/i18n.test.mjs", + "frontend/tests/mainViewLogMessages.test.mjs", + "frontend/tests/reportParsers.test.mjs", + "backend/tests/test_zep_tools_i18n.py" + ], + "validation": [ + "uv run --project backend pytest -q backend/tests/test_graph_builder.py backend/tests/test_graph_upload_api.py", + "uv run --project backend pytest -q backend/tests/test_report_agent.py", + "uv run --project backend pytest -q backend/tests/test_openai_compat_services.py", + "uv run --project backend pytest -q backend/tests/test_zep_tools_i18n.py", + "scripts/test_backend_lite.sh", + "frontend: npm test", + "frontend: npm run build" + ] + }, + "local_status": "partial", + "local_summary": "Safe subset landed locally for persisted EN/ZH UI chrome, locale headers, browser-language-aware first-run locale bootstrap, deterministic Step 2/3 workflow logs, MainView workflow/build logs, locale-aware report and InsightForge sub-query prompt scaffolding, English-localized ReportAgent tool descriptions and parameter help, bilingual Step 4 Insight/Panorama parsing, deterministic Step 5 interview helper fallbacks/prompts, English-mode Oasis profile prompt empty-state fallbacks, English-localized GraphBuilderService worker progress strings, and English fallback labels in `zep_tools` Panorama/InsightForge report output, but broader backend/runtime localization remains a follow-up instead of a blind merge.", + "triage_status": "partial", + "summary": "Safe subset landed locally for persisted EN/ZH UI chrome, locale headers, browser-language-aware first-run locale bootstrap, deterministic Step 2/3 workflow logs, MainView workflow/build logs, locale-aware report and InsightForge sub-query prompt scaffolding, English-localized ReportAgent tool descriptions and parameter help, bilingual Step 4 Insight/Panorama parsing, deterministic Step 5 interview helper fallbacks/prompts, English-mode Oasis profile prompt empty-state fallbacks, English-localized GraphBuilderService worker progress strings, and English fallback labels in `zep_tools` Panorama/InsightForge report output, but broader backend/runtime localization remains a follow-up instead of a blind merge.", + "coverage_status": "partial", + "coverage_summary": "Safe subset landed locally for persisted EN/ZH UI chrome, locale headers, browser-language-aware first-run locale bootstrap, deterministic Step 2/3 workflow logs, MainView workflow/build logs, locale-aware report and InsightForge sub-query prompt scaffolding, English-localized ReportAgent tool descriptions and parameter help, bilingual Step 4 Insight/Panorama parsing, deterministic Step 5 interview helper fallbacks/prompts, English-mode Oasis profile prompt empty-state fallbacks, English-localized GraphBuilderService worker progress strings, and English fallback labels in `zep_tools` Panorama/InsightForge report output, but broader backend/runtime localization remains a follow-up instead of a blind merge.", + "local_review": { + "status": "partial", + "summary": "Safe subset landed locally for persisted EN/ZH UI chrome, locale headers, browser-language-aware first-run locale bootstrap, deterministic Step 2/3 workflow logs, MainView workflow/build logs, locale-aware report and InsightForge sub-query prompt scaffolding, English-localized ReportAgent tool descriptions and parameter help, bilingual Step 4 Insight/Panorama parsing, deterministic Step 5 interview helper fallbacks/prompts, English-mode Oasis profile prompt empty-state fallbacks, English-localized GraphBuilderService worker progress strings, and English fallback labels in `zep_tools` Panorama/InsightForge report output, but broader backend/runtime localization remains a follow-up instead of a blind merge.", + "local_refs": [ + "frontend/src/views/MainView.vue", + "frontend/src/views/mainViewLogMessages.js", + "backend/app/services/graph_builder.py", + "frontend/src/components/Step4Report.vue", + "frontend/src/components/reportParsers.js", + "backend/app/services/report_agent.py", + "backend/app/services/zep_tools.py", + "backend/app/services/oasis_profile_generator.py", + "frontend/src/i18n/index.js", + "frontend/src/i18n/locales/en.js", + "frontend/src/i18n/locales/zh.js", + "backend/tests/test_graph_builder.py", + "backend/tests/test_report_agent.py", + "backend/tests/test_openai_compat_services.py", + "frontend/tests/i18n.test.mjs", + "frontend/tests/mainViewLogMessages.test.mjs", + "frontend/tests/reportParsers.test.mjs", + "backend/tests/test_zep_tools_i18n.py" + ], + "validation": [ + "uv run --project backend pytest -q backend/tests/test_graph_builder.py backend/tests/test_graph_upload_api.py", + "uv run --project backend pytest -q backend/tests/test_report_agent.py", + "uv run --project backend pytest -q backend/tests/test_openai_compat_services.py", + "uv run --project backend pytest -q backend/tests/test_zep_tools_i18n.py", + "scripts/test_backend_lite.sh", + "frontend: npm test", + "frontend: npm run build" + ], + "notes": null + } + }, + { + "number": 118, + "title": "feat(ragflow): add RAGflow as alternative graph backend with full pip…", + "url": "https://github.com/666ghj/MiroFish/pull/118", + "state": "open", + "created_at": "2026-03-10T09:29:33Z", + "updated_at": "2026-03-10T09:30:44Z", + "closed_at": null, + "merged_at": null, + "head": "fix/ragflow-pattern-compliance", + "head_ref_name": "fix/ragflow-pattern-compliance", + "head_sha": "d700c0c7d0a595f20fd415c476a4b3547e1bfb0d", + "head_repo": "pratyush618/MiroFish", + "head_clone_url": "https://github.com/pratyush618/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "documentation", + "enhancement", + "size:XXL" + ], + "author": "pratyush618", + "body_excerpt": "…eline compliance - Add RagflowGraphBuilderService and RagflowEntityReader for self-hosted graph support - Add _get_entity_reader() helper in simulation.py to auto-select reader by graph_id prefix - Fix 4 simulation endpoints (get_graph_entities, get_entity_detail, get_entities_by_type, generate_profiles) to support ragflow_ graph IDs - Guard ZepGraphMemoryManager.create_updater() to skip for RAG…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-118", + "mirror_ref": "origin/mirror/upstream-pr-118", + "local_coverage": { + "number": 118, + "status": "not_safe", + "summary": "Not safe to cherry-pick: RAGflow support threads a second graph backend through core graph/simulation paths without the targeted regression coverage or rebasing needed on top of current local changes." + }, + "local_status": "not_safe", + "local_summary": "Not safe to cherry-pick: RAGflow support threads a second graph backend through core graph/simulation paths without the targeted regression coverage or rebasing needed on top of current local changes.", + "triage_status": "not_safe", + "summary": "Not safe to cherry-pick: RAGflow support threads a second graph backend through core graph/simulation paths without the targeted regression coverage or rebasing needed on top of current local changes.", + "coverage_status": "not_safe", + "coverage_summary": "Not safe to cherry-pick: RAGflow support threads a second graph backend through core graph/simulation paths without the targeted regression coverage or rebasing needed on top of current local changes.", + "local_review": { + "status": "not_safe", + "summary": "Not safe to cherry-pick: RAGflow support threads a second graph backend through core graph/simulation paths without the targeted regression coverage or rebasing needed on top of current local changes.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 116, + "title": "Upgrade GitHub Actions", + "url": "https://github.com/666ghj/MiroFish/pull/116", + "state": "open", + "created_at": "2026-03-10T07:30:38Z", + "updated_at": "2026-03-10T07:30:42Z", + "closed_at": null, + "merged_at": null, + "head": "chore/upgrade-actions", + "head_ref_name": "chore/upgrade-actions", + "head_sha": "ddd20d9b615b660b6fc4604add767aaa30cb2f71", + "head_repo": "ailuntz/MiroFish", + "head_clone_url": "https://github.com/ailuntz/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "size:XS" + ], + "author": "ailuntz", + "body_excerpt": "Fixes #92.\\n\\nBump checkout and build-push to latest major versions.", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-116", + "mirror_ref": "origin/mirror/upstream-pr-116", + "local_coverage": { + "number": 116, + "status": "landed", + "summary": "Landed locally: GitHub Actions dependency upgrades." + }, + "local_status": "landed", + "local_summary": "Landed locally: GitHub Actions dependency upgrades.", + "triage_status": "landed", + "summary": "Landed locally: GitHub Actions dependency upgrades.", + "coverage_status": "landed", + "coverage_summary": "Landed locally: GitHub Actions dependency upgrades.", + "local_review": { + "status": "landed", + "summary": "Landed locally: GitHub Actions dependency upgrades.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 115, + "title": "Use SPDX license string", + "url": "https://github.com/666ghj/MiroFish/pull/115", + "state": "open", + "created_at": "2026-03-10T07:28:37Z", + "updated_at": "2026-03-10T07:28:41Z", + "closed_at": null, + "merged_at": null, + "head": "fix/pyproject-license", + "head_ref_name": "fix/pyproject-license", + "head_sha": "e7a1fbb4c2166c3ed95f0ac40b14c443c35c260b", + "head_repo": "ailuntz/MiroFish", + "head_clone_url": "https://github.com/ailuntz/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "size:XS" + ], + "author": "ailuntz", + "body_excerpt": "Fixes #46.\\n\\nSwitch project.license to SPDX string to avoid the deprecation warning.", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-115", + "mirror_ref": "origin/mirror/upstream-pr-115", + "local_coverage": { + "number": 115, + "status": "landed", + "summary": "Landed locally: SPDX license string metadata cleanup." + }, + "local_status": "landed", + "local_summary": "Landed locally: SPDX license string metadata cleanup.", + "triage_status": "landed", + "summary": "Landed locally: SPDX license string metadata cleanup.", + "coverage_status": "landed", + "coverage_summary": "Landed locally: SPDX license string metadata cleanup.", + "local_review": { + "status": "landed", + "summary": "Landed locally: SPDX license string metadata cleanup.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 114, + "title": "Fix API base URL fallback", + "url": "https://github.com/666ghj/MiroFish/pull/114", + "state": "open", + "created_at": "2026-03-10T07:21:29Z", + "updated_at": "2026-03-10T07:21:49Z", + "closed_at": null, + "merged_at": null, + "head": "fix/api-baseurl-default", + "head_ref_name": "fix/api-baseurl-default", + "head_sha": "866c7849c3bf9a35dea560e5f22030b3fab03c53", + "head_repo": "ailuntz/MiroFish", + "head_clone_url": "https://github.com/ailuntz/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "size:S" + ], + "author": "ailuntz", + "body_excerpt": "Fixes #93.\\n\\nUse VITE_API_BASE_URL when set; otherwise default to current origin.", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-114", + "mirror_ref": "origin/mirror/upstream-pr-114", + "local_coverage": { + "number": 114, + "status": "superseded", + "summary": "Superseded locally by the current frontend API client, which already falls back to the runtime origin and supports custom base URLs." + }, + "local_status": "superseded", + "local_summary": "Superseded locally by the current frontend API client, which already falls back to the runtime origin and supports custom base URLs.", + "triage_status": "superseded", + "summary": "Superseded locally by the current frontend API client, which already falls back to the runtime origin and supports custom base URLs.", + "coverage_status": "superseded", + "coverage_summary": "Superseded locally by the current frontend API client, which already falls back to the runtime origin and supports custom base URLs.", + "local_review": { + "status": "superseded", + "summary": "Superseded locally by the current frontend API client, which already falls back to the runtime origin and supports custom base URLs.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 113, + "title": "docs(readme): add Japanese README", + "url": "https://github.com/666ghj/MiroFish/pull/113", + "state": "open", + "created_at": "2026-03-10T06:43:16Z", + "updated_at": "2026-03-10T06:44:34Z", + "closed_at": null, + "merged_at": null, + "head": "add-ja-doc", + "head_ref_name": "add-ja-doc", + "head_sha": "0531fa640d186bf27de03c01d4bcc4bcd315cf4d", + "head_repo": "eltociear/MiroFish", + "head_clone_url": "https://github.com/eltociear/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "documentation", + "size:L" + ], + "author": "eltociear", + "body_excerpt": "I created Japanese translated README.", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-113", + "mirror_ref": "origin/mirror/upstream-pr-113", + "local_coverage": { + "number": 113, + "status": "landed", + "summary": "Landed locally: Japanese README added and cross-links normalized." + }, + "local_status": "landed", + "local_summary": "Landed locally: Japanese README added and cross-links normalized.", + "triage_status": "landed", + "summary": "Landed locally: Japanese README added and cross-links normalized.", + "coverage_status": "landed", + "coverage_summary": "Landed locally: Japanese README added and cross-links normalized.", + "local_review": { + "status": "landed", + "summary": "Landed locally: Japanese README added and cross-links normalized.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 112, + "title": "Korean README.md added", + "url": "https://github.com/666ghj/MiroFish/pull/112", + "state": "open", + "created_at": "2026-03-10T05:25:01Z", + "updated_at": "2026-03-10T05:25:55Z", + "closed_at": null, + "merged_at": null, + "head": "main", + "head_ref_name": "main", + "head_sha": "9817c535d77dbd69ccefa58ca96cbae497dbff47", + "head_repo": "waitle/MiroFish-kor", + "head_clone_url": "https://github.com/waitle/MiroFish-kor.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "documentation", + "size:L" + ], + "author": "waitle", + "body_excerpt": "AI translated Korean readme file", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-112", + "mirror_ref": "origin/mirror/upstream-pr-112", + "local_coverage": { + "number": 112, + "status": "landed", + "summary": "Landed locally: Korean README added and cross-links normalized." + }, + "local_status": "landed", + "local_summary": "Landed locally: Korean README added and cross-links normalized.", + "triage_status": "landed", + "summary": "Landed locally: Korean README added and cross-links normalized.", + "coverage_status": "landed", + "coverage_summary": "Landed locally: Korean README added and cross-links normalized.", + "local_review": { + "status": "landed", + "summary": "Landed locally: Korean README added and cross-links normalized.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 72, + "title": "清理模型返回的markdown标记", + "url": "https://github.com/666ghj/MiroFish/pull/72", + "state": "open", + "created_at": "2026-02-15T05:27:05Z", + "updated_at": "2026-03-10T02:58:45Z", + "closed_at": null, + "merged_at": null, + "head": "main", + "head_ref_name": "main", + "head_sha": "42f9f9a72caf08f77d1db969ae7d7116d1a28d5f", + "head_repo": "MoeclubM/MiroFish", + "head_clone_url": "https://github.com/MoeclubM/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "dirty", + "labels": [ + "size:S" + ], + "author": "MoeclubM", + "body_excerpt": "生成本体结构时部分模型(已测试minimax-m2.1 minimax-m2.5 glm-4.7 glm-5都有相同情况)似乎不遵守json_object的格式,会返回markdown包裹的json代码块 导致json.loads()解析错误 对md代码块标记进行了清理并额外增加了try except防止出错导致500 https://github.com/666ghj/MiroFish/issues/64 https://github.com/666ghj/MiroFish/issues/58 https://github.com/666ghj/MiroFish/issues/48 不确定是否都是这个原因导致的", + "comment_count": 3, + "review_comment_count": 3, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-03-05T16:07:48Z", + "updated_at": "2026-03-05T16:07:48Z", + "url": "https://github.com/666ghj/MiroFish/pull/72#issuecomment-4006153143", + "body_excerpt": "经过测试好像这样依旧会500,你调试的时候是可以解决问题的吗?" + }, + { + "author": "666ghj", + "created_at": "2026-03-05T16:19:41Z", + "updated_at": "2026-03-05T16:19:41Z", + "url": "https://github.com/666ghj/MiroFish/pull/72#issuecomment-4006236547", + "body_excerpt": "我找到原因了, MiniMax M2.5 是推理模型,即使通过 OpenAI 兼容 API 调用,其 content 字段会包含 <think>...</think> 思维链内容。实际返回大致如下: ``` <think> 用户需要我生成一个本体结构...让我分析这些文档... </think> ```json {\"entity_types\": [...], \"edge_types\": [...]} ``` 而 `chat_json()` 直接对整个 `content` 做…" + }, + { + "author": "Gresdy", + "created_at": "2026-03-10T02:58:06Z", + "updated_at": "2026-03-10T02:58:45Z", + "url": "https://github.com/666ghj/MiroFish/pull/72#issuecomment-4028265481", + "body_excerpt": "最新的代码,今天使用minimax2.5还是存在500错误 [backend] [02:52:07] INFO: 调用 LLM 生成本体定义... [backend] 192.168.65.1 - - [10/Mar/2026 02:52:09] \"POST /api/graph/ontology/generate HTTP/1.1\" 500 -" + } + ], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-72", + "mirror_ref": "origin/mirror/upstream-pr-72", + "local_coverage": { + "number": 72, + "status": "superseded", + "summary": "Superseded locally by the broader `_extract_json_payload()` handling in `backend/app/utils/llm_client.py`." + }, + "local_status": "superseded", + "local_summary": "Superseded locally by the broader `_extract_json_payload()` handling in `backend/app/utils/llm_client.py`.", + "triage_status": "superseded", + "summary": "Superseded locally by the broader `_extract_json_payload()` handling in `backend/app/utils/llm_client.py`.", + "coverage_status": "superseded", + "coverage_summary": "Superseded locally by the broader `_extract_json_payload()` handling in `backend/app/utils/llm_client.py`.", + "local_review": { + "status": "superseded", + "summary": "Superseded locally by the broader `_extract_json_payload()` handling in `backend/app/utils/llm_client.py`.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 108, + "title": "feat(installer): add Windows installer build scripts", + "url": "https://github.com/666ghj/MiroFish/pull/108", + "state": "open", + "created_at": "2026-03-09T17:14:55Z", + "updated_at": "2026-03-09T17:16:21Z", + "closed_at": null, + "merged_at": null, + "head": "feat/windows-installer", + "head_ref_name": "feat/windows-installer", + "head_sha": "27ffc561637a2fd67a87415691dd5fddeb4a2e2d", + "head_repo": "JasonOA888/MiroFish", + "head_clone_url": "https://github.com/JasonOA888/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "enhancement", + "size:XL" + ], + "author": "JasonOA888", + "body_excerpt": "Implements #70 - Windows installation program packaging ## Features - PowerShell build script with embedded Python and PyInstaller modes - Inno Setup integration for professional installer - Portable version generation - API key configuration during installation - Desktop and Start Menu shortcuts ## Build Options ``` ./installer/build.ps1 # Default (embedded Python) ./installer/build.ps1 -PyInsta…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-108", + "mirror_ref": "origin/mirror/upstream-pr-108", + "local_coverage": { + "number": 108, + "status": "not_safe", + "summary": "Not safe to cherry-pick: the Windows packaging flow targets the wrong runtime entrypoints, serves the frontend in dev mode, and rebundles already-landed workflow changes." + }, + "local_status": "not_safe", + "local_summary": "Not safe to cherry-pick: the Windows packaging flow targets the wrong runtime entrypoints, serves the frontend in dev mode, and rebundles already-landed workflow changes.", + "triage_status": "not_safe", + "summary": "Not safe to cherry-pick: the Windows packaging flow targets the wrong runtime entrypoints, serves the frontend in dev mode, and rebundles already-landed workflow changes.", + "coverage_status": "not_safe", + "coverage_summary": "Not safe to cherry-pick: the Windows packaging flow targets the wrong runtime entrypoints, serves the frontend in dev mode, and rebundles already-landed workflow changes.", + "local_review": { + "status": "not_safe", + "summary": "Not safe to cherry-pick: the Windows packaging flow targets the wrong runtime entrypoints, serves the frontend in dev mode, and rebundles already-landed workflow changes.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 104, + "title": "fix: make vite proxy target configurable via environment variable", + "url": "https://github.com/666ghj/MiroFish/pull/104", + "state": "open", + "created_at": "2026-03-09T09:05:37Z", + "updated_at": "2026-03-09T09:06:54Z", + "closed_at": null, + "merged_at": null, + "head": "fix/remove-hardcoded-api-url", + "head_ref_name": "fix/remove-hardcoded-api-url", + "head_sha": "6f2d41262fc4869b2037bcdda7d60cf20de4f6f1", + "head_repo": "nil957/MiroFish", + "head_clone_url": "https://github.com/nil957/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "documentation", + "size:M" + ], + "author": "nil957", + "body_excerpt": "## Summary This PR addresses issue #93 - `frontend/src/api/index.js`中的`baseURL`不应该硬编码 ## Problem While `api/index.js` already supports `VITE_API_BASE_URL`, the vite dev server proxy in `vite.config.js` was still hardcoded to `http://localhost:5001`, causing issues when: - Deploying with Docker using custom port mappings - Running backend on a remote server - Using non-standard ports ## Changes 1.…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-104", + "mirror_ref": "origin/mirror/upstream-pr-104", + "local_coverage": { + "number": 104, + "status": "landed", + "summary": "Landed locally: Vite dev proxy target is configurable via `VITE_API_BASE_URL`." + }, + "local_status": "landed", + "local_summary": "Landed locally: Vite dev proxy target is configurable via `VITE_API_BASE_URL`.", + "triage_status": "landed", + "summary": "Landed locally: Vite dev proxy target is configurable via `VITE_API_BASE_URL`.", + "coverage_status": "landed", + "coverage_summary": "Landed locally: Vite dev proxy target is configurable via `VITE_API_BASE_URL`.", + "local_review": { + "status": "landed", + "summary": "Landed locally: Vite dev proxy target is configurable via `VITE_API_BASE_URL`.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 103, + "title": "ci: upgrade GitHub Actions and add ARM64 Docker support", + "url": "https://github.com/666ghj/MiroFish/pull/103", + "state": "open", + "created_at": "2026-03-09T09:03:45Z", + "updated_at": "2026-03-09T09:04:59Z", + "closed_at": null, + "merged_at": null, + "head": "fix/upgrade-actions-and-arm-support", + "head_ref_name": "fix/upgrade-actions-and-arm-support", + "head_sha": "40e92a66f2b91e9fc211e226f4beab47136170cb", + "head_repo": "nil957/MiroFish", + "head_clone_url": "https://github.com/nil957/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "enhancement", + "size:XS" + ], + "author": "nil957", + "body_excerpt": "## Summary This PR addresses two issues: - #92 - Upgrade GitHub Actions - #99 - Docker镜像没有arm版本 ## Changes 1. **Upgraded docker/build-push-action** from v5 to v6 2. **Added multi-platform build support** for both `linux/amd64` and `linux/arm64` 3. **Added GitHub Actions cache** (`cache-from` and `cache-to`) for faster subsequent builds ## Benefits - Users on ARM-based machines can now use the off…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-103", + "mirror_ref": "origin/mirror/upstream-pr-103", + "local_coverage": { + "number": 103, + "status": "landed", + "summary": "Landed locally: Docker workflow now builds ARM64 images and carries the related cache/buildx improvements." + }, + "local_status": "landed", + "local_summary": "Landed locally: Docker workflow now builds ARM64 images and carries the related cache/buildx improvements.", + "triage_status": "landed", + "summary": "Landed locally: Docker workflow now builds ARM64 images and carries the related cache/buildx improvements.", + "coverage_status": "landed", + "coverage_summary": "Landed locally: Docker workflow now builds ARM64 images and carries the related cache/buildx improvements.", + "local_review": { + "status": "landed", + "summary": "Landed locally: Docker workflow now builds ARM64 images and carries the related cache/buildx improvements.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 102, + "title": "fix(ci): add multi-platform Docker build for ARM64 support", + "url": "https://github.com/666ghj/MiroFish/pull/102", + "state": "open", + "created_at": "2026-03-09T08:25:27Z", + "updated_at": "2026-03-09T08:26:30Z", + "closed_at": null, + "merged_at": null, + "head": "fix/docker-multiplatform", + "head_ref_name": "fix/docker-multiplatform", + "head_sha": "14afd56e2c53b6f06692f2e00ce53f7ecb2aca9f", + "head_repo": "JasonOA888/MiroFish", + "head_clone_url": "https://github.com/JasonOA888/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "enhancement", + "size:XS" + ], + "author": "JasonOA888", + "body_excerpt": "## Problem ARM64 machines (e.g., Apple Silicon Macs, ARM servers) cannot deploy MiroFish via Docker: ``` no matching manifest for linux/arm64/v8 in the manifest list entries ``` ## Solution Add `platforms: linux/amd64,linux/arm64` to `docker/build-push-action`. The workflow already has QEMU and Buildx configured, just needed the platforms flag. ## Changes - Update `.github/workflows/docker-image.…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-102", + "mirror_ref": "origin/mirror/upstream-pr-102", + "local_coverage": { + "number": 102, + "status": "superseded", + "summary": "Superseded locally by the landed multi-platform Docker workflow that already builds `linux/amd64` and `linux/arm64` images." + }, + "local_status": "superseded", + "local_summary": "Superseded locally by the landed multi-platform Docker workflow that already builds `linux/amd64` and `linux/arm64` images.", + "triage_status": "superseded", + "summary": "Superseded locally by the landed multi-platform Docker workflow that already builds `linux/amd64` and `linux/arm64` images.", + "coverage_status": "superseded", + "coverage_summary": "Superseded locally by the landed multi-platform Docker workflow that already builds `linux/amd64` and `linux/arm64` images.", + "local_review": { + "status": "superseded", + "summary": "Superseded locally by the landed multi-platform Docker workflow that already builds `linux/amd64` and `linux/arm64` images.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 101, + "title": "feat(utils): add json_utils module for robust LLM JSON parsing", + "url": "https://github.com/666ghj/MiroFish/pull/101", + "state": "open", + "created_at": "2026-03-09T08:23:07Z", + "updated_at": "2026-03-09T08:24:20Z", + "closed_at": null, + "merged_at": null, + "head": "feat/json-utils-helper", + "head_ref_name": "feat/json-utils-helper", + "head_sha": "b46ee9befd1f792b986b758920810098ce582489", + "head_repo": "JasonOA888/MiroFish", + "head_clone_url": "https://github.com/JasonOA888/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "enhancement", + "size:M" + ], + "author": "JasonOA888", + "body_excerpt": "## Problem Some LLM models don't respect `json_object` format and return markdown-wrapped JSON: ``` ```json {\"key\": \"value\"} ``` ``` This causes `json.loads()` to fail with JSONDecodeError. Affected models: MiniMax M2.5, GLM-4.7, GLM-5 Related issues: #72, #64, #58, #48 ## Solution Add `json_utils.py` module with: - `clean_llm_json_response()` - Strip markdown code blocks - `parse_llm_json()` - P…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-101", + "mirror_ref": "origin/mirror/upstream-pr-101", + "local_coverage": { + "number": 101, + "status": "superseded", + "summary": "Superseded locally: the useful JSON-cleanup intent is already covered by the current LLM payload extraction hardening, and the branch predates substantial newer backend/frontend work." + }, + "local_status": "superseded", + "local_summary": "Superseded locally: the useful JSON-cleanup intent is already covered by the current LLM payload extraction hardening, and the branch predates substantial newer backend/frontend work.", + "triage_status": "superseded", + "summary": "Superseded locally: the useful JSON-cleanup intent is already covered by the current LLM payload extraction hardening, and the branch predates substantial newer backend/frontend work.", + "coverage_status": "superseded", + "coverage_summary": "Superseded locally: the useful JSON-cleanup intent is already covered by the current LLM payload extraction hardening, and the branch predates substantial newer backend/frontend work.", + "local_review": { + "status": "superseded", + "summary": "Superseded locally: the useful JSON-cleanup intent is already covered by the current LLM payload extraction hardening, and the branch predates substantial newer backend/frontend work.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 100, + "title": "fix(frontend): use relative baseURL in production, avoid hardcoded localhost", + "url": "https://github.com/666ghj/MiroFish/pull/100", + "state": "open", + "created_at": "2026-03-09T08:19:23Z", + "updated_at": "2026-03-09T08:20:38Z", + "closed_at": null, + "merged_at": null, + "head": "fix/frontend-baseurl", + "head_ref_name": "fix/frontend-baseurl", + "head_sha": "1e3451b05814d612c0864013c73f6bcd5b410f04", + "head_repo": "JasonOA888/MiroFish", + "head_clone_url": "https://github.com/JasonOA888/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "enhancement", + "size:S" + ], + "author": "JasonOA888", + "body_excerpt": "## Problem Frontend `baseURL` defaults to `http://localhost:5001` in all environments, causing: 1. Users can only access from the machine running MiroFish 2. Docker deployments with non-5001 ports fail 3. Server deployments require frontend rebuild to change URL ## Solution Make baseURL environment-aware: - **Production**: Use relative path (empty string) for same-origin deployment - **Developmen…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-100", + "mirror_ref": "origin/mirror/upstream-pr-100", + "local_coverage": { + "number": 100, + "status": "superseded", + "summary": "Superseded locally by the shared frontend API base-url resolver, which already uses runtime-origin fallback and repo-specific localhost handling." + }, + "local_status": "superseded", + "local_summary": "Superseded locally by the shared frontend API base-url resolver, which already uses runtime-origin fallback and repo-specific localhost handling.", + "triage_status": "superseded", + "summary": "Superseded locally by the shared frontend API base-url resolver, which already uses runtime-origin fallback and repo-specific localhost handling.", + "coverage_status": "superseded", + "coverage_summary": "Superseded locally by the shared frontend API base-url resolver, which already uses runtime-origin fallback and repo-specific localhost handling.", + "local_review": { + "status": "superseded", + "summary": "Superseded locally by the shared frontend API base-url resolver, which already uses runtime-origin fallback and repo-specific localhost handling.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 87, + "title": "Upgrade GitHub Actions to latest versions", + "url": "https://github.com/666ghj/MiroFish/pull/87", + "state": "open", + "created_at": "2026-03-08T09:09:13Z", + "updated_at": "2026-03-08T09:09:19Z", + "closed_at": null, + "merged_at": null, + "head": "upgrade-github-actions-node24-general", + "head_ref_name": "upgrade-github-actions-node24-general", + "head_sha": "793d1581107a1fd4ffe120ff71ba82f674a8f206", + "head_repo": "salmanmkc/MiroFish", + "head_clone_url": "https://github.com/salmanmkc/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "size:S" + ], + "author": "salmanmkc", + "body_excerpt": "## Summary Upgrade GitHub Actions to their latest versions for improved features, bug fixes, and security updates. ## Changes | Action | Old Version(s) | New Version | Release | Files | |--------|---------------|-------------|---------|-------| | `docker/build-push-action` | [`v5`](https://github.com/docker/build-push-action/releases/tag/v5) | [`v7`](https://github.com/docker/build-push-action/re…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-87", + "mirror_ref": "origin/mirror/upstream-pr-87", + "local_coverage": { + "number": 87, + "status": "superseded", + "summary": "Superseded locally by the broader GitHub Actions upgrade sweep from upstream PR #116." + }, + "local_status": "superseded", + "local_summary": "Superseded locally by the broader GitHub Actions upgrade sweep from upstream PR #116.", + "triage_status": "superseded", + "summary": "Superseded locally by the broader GitHub Actions upgrade sweep from upstream PR #116.", + "coverage_status": "superseded", + "coverage_summary": "Superseded locally by the broader GitHub Actions upgrade sweep from upstream PR #116.", + "local_review": { + "status": "superseded", + "summary": "Superseded locally by the broader GitHub Actions upgrade sweep from upstream PR #116.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 86, + "title": "Upgrade GitHub Actions for Node 24 compatibility", + "url": "https://github.com/666ghj/MiroFish/pull/86", + "state": "open", + "created_at": "2026-03-08T09:09:10Z", + "updated_at": "2026-03-08T09:09:14Z", + "closed_at": null, + "merged_at": null, + "head": "upgrade-github-actions-node24", + "head_ref_name": "upgrade-github-actions-node24", + "head_sha": "265a89bf577ca0e9e9c91090bbf161abc350f25b", + "head_repo": "salmanmkc/MiroFish", + "head_clone_url": "https://github.com/salmanmkc/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "size:XS" + ], + "author": "salmanmkc", + "body_excerpt": "## Summary Upgrade GitHub Actions to their latest versions to ensure compatibility with Node 24, as Node 20 will reach end-of-life in April 2026. ## Changes | Action | Old Version(s) | New Version | Release | Files | |--------|---------------|-------------|---------|-------| | `actions/checkout` | [`v4`](https://github.com/actions/checkout/releases/tag/v4) | [`v6`](https://github.com/actions/chec…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-86", + "mirror_ref": "origin/mirror/upstream-pr-86", + "local_coverage": { + "number": 86, + "status": "superseded", + "summary": "Superseded locally by the newer workflow updates already carried on this branch, including the later ARM64/cache improvements." + }, + "local_status": "superseded", + "local_summary": "Superseded locally by the newer workflow updates already carried on this branch, including the later ARM64/cache improvements.", + "triage_status": "superseded", + "summary": "Superseded locally by the newer workflow updates already carried on this branch, including the later ARM64/cache improvements.", + "coverage_status": "superseded", + "coverage_summary": "Superseded locally by the newer workflow updates already carried on this branch, including the later ARM64/cache improvements.", + "local_review": { + "status": "superseded", + "summary": "Superseded locally by the newer workflow updates already carried on this branch, including the later ARM64/cache improvements.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 82, + "title": "[Security] Fix CRITICAL vulnerability: CVE-2025-64712", + "url": "https://github.com/666ghj/MiroFish/pull/82", + "state": "open", + "created_at": "2026-03-08T02:45:42Z", + "updated_at": "2026-03-08T02:45:46Z", + "closed_at": null, + "merged_at": null, + "head": "fix-cve-2025-64712-unstructured", + "head_ref_name": "fix-cve-2025-64712-unstructured", + "head_sha": "c0b1fee634ab03334f6eb1914a0498bc9b172056", + "head_repo": "orbisai0security/MiroFish", + "head_clone_url": "https://github.com/orbisai0security/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "size:XS" + ], + "author": "orbisai0security", + "body_excerpt": "## Security Fix This PR addresses a **CRITICAL** severity vulnerability detected by our security scanner. ### Security Impact Assessment | Aspect | Rating | Rationale | |--------|--------|-----------| | Impact | Critical | In MiroFish's backend, which likely processes unstructured data including MSG files via the Unstructured library, exploitation could allow an attacker to write arbitrary files…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-82", + "mirror_ref": "origin/mirror/upstream-pr-82", + "local_coverage": { + "number": 82, + "status": "covered", + "summary": "The underlying dependency-risk issue is addressed locally without cherry-picking: simulation/runtime packages moved behind an explicit optional install path instead of re-adding `unstructured` to the default backend dependencies, and the remaining optional simulation lock now resolves `pillow==10.4.0`." + }, + "local_status": "covered", + "local_summary": "The underlying dependency-risk issue is addressed locally without cherry-picking: simulation/runtime packages moved behind an explicit optional install path instead of re-adding `unstructured` to the default backend dependencies, and the remaining optional simulation lock now resolves `pillow==10.4.0`.", + "triage_status": "covered", + "summary": "The underlying dependency-risk issue is addressed locally without cherry-picking: simulation/runtime packages moved behind an explicit optional install path instead of re-adding `unstructured` to the default backend dependencies, and the remaining optional simulation lock now resolves `pillow==10.4.0`.", + "coverage_status": "covered", + "coverage_summary": "The underlying dependency-risk issue is addressed locally without cherry-picking: simulation/runtime packages moved behind an explicit optional install path instead of re-adding `unstructured` to the default backend dependencies, and the remaining optional simulation lock now resolves `pillow==10.4.0`.", + "local_review": { + "status": "covered", + "summary": "The underlying dependency-risk issue is addressed locally without cherry-picking: simulation/runtime packages moved behind an explicit optional install path instead of re-adding `unstructured` to the default backend dependencies, and the remaining optional simulation lock now resolves `pillow==10.4.0`.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 81, + "title": "feat: add configurable API timeout for slow local LLMs", + "url": "https://github.com/666ghj/MiroFish/pull/81", + "state": "open", + "created_at": "2026-03-08T02:34:41Z", + "updated_at": "2026-03-08T02:35:36Z", + "closed_at": null, + "merged_at": null, + "head": "fix/issue-58-configurable-timeout", + "head_ref_name": "fix/issue-58-configurable-timeout", + "head_sha": "92efb3616f40027ade6ed2b7789bc27e3ab036f8", + "head_repo": "JasonOA888/MiroFish", + "head_clone_url": "https://github.com/JasonOA888/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "LLM API", + "size:XS" + ], + "author": "JasonOA888", + "body_excerpt": "## Summary Fixes #58 - 允许用户配置API超时时间以支持响应较慢的本地大模型(如Ollama)。 ## Changes - 添加 `VITE_API_TIMEOUT` 环境变量支持 - 默认保持300000ms(5分钟) - 用户可根据需要增加超时时间 ## Usage 在 `.env` 文件中添加: ```bash # 本地大模型响应较慢时增加超时时间 VITE_API_TIMEOUT=600000 # 10分钟 ``` ## Testing - 默认值300000ms正常工作 - 配置后使用配置的值 Fixes #58", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-81", + "mirror_ref": "origin/mirror/upstream-pr-81", + "local_coverage": { + "number": 81, + "status": "landed", + "summary": "Landed locally: frontend API timeout is configurable for slow local and OpenAI-compatible backends." + }, + "local_status": "landed", + "local_summary": "Landed locally: frontend API timeout is configurable for slow local and OpenAI-compatible backends.", + "triage_status": "landed", + "summary": "Landed locally: frontend API timeout is configurable for slow local and OpenAI-compatible backends.", + "coverage_status": "landed", + "coverage_summary": "Landed locally: frontend API timeout is configurable for slow local and OpenAI-compatible backends.", + "local_review": { + "status": "landed", + "summary": "Landed locally: frontend API timeout is configurable for slow local and OpenAI-compatible backends.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 74, + "title": "fix: replace 4 bare excepts with except Exception", + "url": "https://github.com/666ghj/MiroFish/pull/74", + "state": "open", + "created_at": "2026-02-25T03:01:43Z", + "updated_at": "2026-02-25T09:20:00Z", + "closed_at": null, + "merged_at": null, + "head": "fix/bare-excepts", + "head_ref_name": "fix/bare-excepts", + "head_sha": "1612dfe017c5bebc963ae2838096b66ce134ba05", + "head_repo": "haosenwang1018/MiroFish", + "head_clone_url": "https://github.com/haosenwang1018/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "size:XS" + ], + "author": "haosenwang1018", + "body_excerpt": "Bare `except:` → `except Exception:` in 3 backend files (4 sites).", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-74", + "mirror_ref": "origin/mirror/upstream-pr-74", + "local_coverage": { + "number": 74, + "status": "landed", + "summary": "Landed locally: bare `except:` clauses in the reviewed JSON/simulation paths were narrowed to `except Exception:`." + }, + "local_status": "landed", + "local_summary": "Landed locally: bare `except:` clauses in the reviewed JSON/simulation paths were narrowed to `except Exception:`.", + "triage_status": "landed", + "summary": "Landed locally: bare `except:` clauses in the reviewed JSON/simulation paths were narrowed to `except Exception:`.", + "coverage_status": "landed", + "coverage_summary": "Landed locally: bare `except:` clauses in the reviewed JSON/simulation paths were narrowed to `except Exception:`.", + "local_review": { + "status": "landed", + "summary": "Landed locally: bare `except:` clauses in the reviewed JSON/simulation paths were narrowed to `except Exception:`.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 73, + "title": "fix: Handle string entities/edges in _validate_and_process", + "url": "https://github.com/666ghj/MiroFish/pull/73", + "state": "open", + "created_at": "2026-02-20T12:41:04Z", + "updated_at": "2026-02-20T12:42:03Z", + "closed_at": null, + "merged_at": null, + "head": "fix-ontology-validation", + "head_ref_name": "fix-ontology-validation", + "head_sha": "6fce4705ff255915ccdb76f9402983fe43cbc9cd", + "head_repo": "calvinguo721/MiroFish", + "head_clone_url": "https://github.com/calvinguo721/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "LLM API", + "size:S" + ], + "author": "calvinguo721", + "body_excerpt": "## Problem When LLM returns malformed JSON where `entity_types` or `edge_types` contain strings instead of dictionaries, the `_validate_and_process` method crashes with: ``` TypeError: 'str' object does not support item assignment ``` ## Solution Added type checking in `_validate_and_process`: - If entity/edge is a string, wrap it into proper dict format - Skip invalid (non-dict) entries to preve…", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-73", + "mirror_ref": "origin/mirror/upstream-pr-73", + "local_coverage": { + "number": 73, + "status": "landed", + "summary": "Landed locally: malformed ontology entity and edge items are sanitized before fallback injection." + }, + "local_status": "landed", + "local_summary": "Landed locally: malformed ontology entity and edge items are sanitized before fallback injection.", + "triage_status": "landed", + "summary": "Landed locally: malformed ontology entity and edge items are sanitized before fallback injection.", + "coverage_status": "landed", + "coverage_summary": "Landed locally: malformed ontology entity and edge items are sanitized before fallback injection.", + "local_review": { + "status": "landed", + "summary": "Landed locally: malformed ontology entity and edge items are sanitized before fallback injection.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 70, + "title": "windows安装程序打包", + "url": "https://github.com/666ghj/MiroFish/pull/70", + "state": "open", + "created_at": "2026-02-10T14:47:00Z", + "updated_at": "2026-02-16T20:17:26Z", + "closed_at": null, + "merged_at": null, + "head": "main", + "head_ref_name": "main", + "head_sha": "406ac1df624220a154552548173118769098b14f", + "head_repo": "Jonah-Wu23/MiroFish_exe", + "head_clone_url": "https://github.com/Jonah-Wu23/MiroFish_exe.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [ + "enhancement", + "size:XXL" + ], + "author": "Jonah-Wu23", + "body_excerpt": "### 我做的改动 - 将原项目打包为 **Windows 可安装程序(.exe)** - 安装过程中可直接 **填写 API Key(按提示输入)** - 安装完成后可 **双击主程序自动打开浏览器界面** - 实现 Windows 端 **一键安装与运行体验** ### 打包方法: 使用嵌入式 Python 模式打包(体积较小,适合大多数情况): ```powershell .\\installer\\build.ps1 ``` 也可以使用PyInstaller 打包: ```powershell .\\installer\\build.ps1 -PyInstaller ``` > ⚠️ 注意:PyInstaller 模式打包时间长,输出文件可能超过 1GB 如果只需要部分步骤,可以使用参数跳过: ```powershell # 跳过前端构建(如果前端没有修改) .\\installer\\bu…", + "comment_count": 1, + "review_comment_count": 0, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-02-16T20:17:26Z", + "updated_at": "2026-02-16T20:17:26Z", + "url": "https://github.com/666ghj/MiroFish/pull/70#issuecomment-3910367836", + "body_excerpt": "👍👍" + } + ], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-70", + "mirror_ref": "origin/mirror/upstream-pr-70", + "local_coverage": { + "number": 70, + "status": "not_safe", + "summary": "Not safe to cherry-pick: the Windows installer flow assumes a repo topology that does not match the current `run.py` plus built-frontend layout." + }, + "local_status": "not_safe", + "local_summary": "Not safe to cherry-pick: the Windows installer flow assumes a repo topology that does not match the current `run.py` plus built-frontend layout.", + "triage_status": "not_safe", + "summary": "Not safe to cherry-pick: the Windows installer flow assumes a repo topology that does not match the current `run.py` plus built-frontend layout.", + "coverage_status": "not_safe", + "coverage_summary": "Not safe to cherry-pick: the Windows installer flow assumes a repo topology that does not match the current `run.py` plus built-frontend layout.", + "local_review": { + "status": "not_safe", + "summary": "Not safe to cherry-pick: the Windows installer flow assumes a repo topology that does not match the current `run.py` plus built-frontend layout.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 38, + "title": "feat: Add support for Anthropic SDK (Claude) and multi-provider switching", + "url": "https://github.com/666ghj/MiroFish/pull/38", + "state": "open", + "created_at": "2026-01-20T09:56:00Z", + "updated_at": "2026-01-24T16:25:40Z", + "closed_at": null, + "merged_at": null, + "head": "feat/anthropic-sdk", + "head_ref_name": "feat/anthropic-sdk", + "head_sha": "9b795e1bd9a4f57feee53354328dc60acab9fd63", + "head_repo": "SmartisanNaive/MiroFish", + "head_clone_url": "https://github.com/SmartisanNaive/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "dirty", + "labels": [], + "author": "SmartisanNaive", + "body_excerpt": "This PR introduces native support for the **Anthropic (Claude)** SDK to MiroFish! While the project already supports various models via the OpenAI-compatible interface, integrating the official Anthropic SDK allows us to better leverage the capabilities of models like Claude 4 Sonnet. It also ensures compatibility with providers that follow the Anthropic protocol (e.g., Zhipu AI's GLM-4.7 endpoin…", + "comment_count": 2, + "review_comment_count": 0, + "recent_comments": [ + { + "author": "666ghj", + "created_at": "2026-01-24T16:20:22Z", + "updated_at": "2026-01-24T16:20:22Z", + "url": "https://github.com/666ghj/MiroFish/pull/38#issuecomment-3794970052", + "body_excerpt": "I appreciate it, but considering that supporting an additional API format would bring unnecessary workload to the subsequent update plans, I will keep this PR to provide ideas for everyone, but it will not be merged before the release of v…" + }, + { + "author": "SmartisanNaive", + "created_at": "2026-01-24T16:25:40Z", + "updated_at": "2026-01-24T16:25:40Z", + "url": "https://github.com/666ghj/MiroFish/pull/38#issuecomment-3794988321", + "body_excerpt": "Thanks for reading , this is a great project.Your project has brought a lot of inspiration to our researchers working on multi-agent applications, thanks again(●'◡'●) ---Original--- From: ***@***.***> Date: Sun, Jan 25, 2026 00:20 AM To…" + } + ], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-38", + "mirror_ref": "origin/mirror/upstream-pr-38", + "local_coverage": { + "number": 38, + "status": "not_safe", + "summary": "Not safe to cherry-pick: it expands the backend API surface to Anthropic-specific protocol work while this branch is intentionally standardizing on OpenAI-compatible gateways." + }, + "local_status": "not_safe", + "local_summary": "Not safe to cherry-pick: it expands the backend API surface to Anthropic-specific protocol work while this branch is intentionally standardizing on OpenAI-compatible gateways.", + "triage_status": "not_safe", + "summary": "Not safe to cherry-pick: it expands the backend API surface to Anthropic-specific protocol work while this branch is intentionally standardizing on OpenAI-compatible gateways.", + "coverage_status": "not_safe", + "coverage_summary": "Not safe to cherry-pick: it expands the backend API surface to Anthropic-specific protocol work while this branch is intentionally standardizing on OpenAI-compatible gateways.", + "local_review": { + "status": "not_safe", + "summary": "Not safe to cherry-pick: it expands the backend API surface to Anthropic-specific protocol work while this branch is intentionally standardizing on OpenAI-compatible gateways.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 49, + "title": "记忆图谱本地化实现", + "url": "https://github.com/666ghj/MiroFish/pull/49", + "state": "open", + "created_at": "2026-01-22T06:42:21Z", + "updated_at": "2026-01-22T06:42:21Z", + "closed_at": null, + "merged_at": null, + "head": "feat/local", + "head_ref_name": "feat/local", + "head_sha": "b04d7f7e4fb014f376ac4085a3d6a83929a61cc3", + "head_repo": "Momoyeyu/MiroFish", + "head_clone_url": "https://github.com/Momoyeyu/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "dirty", + "labels": [], + "author": "Momoyeyu", + "body_excerpt": "实现以下了能力: 1. 基于[MiroFishOpt](https://github.com/jwc19890114/MiroFishOpt),实现本地图谱构建 2. 参考Zep论文,实现记忆图谱的更新 3. 实现图谱节点的去重 存在不足: 1. 尚未实现Zep的时序能力,无法识别并标记过期的记忆 2. 图谱的丰富节点程度不如Zep 3. 使用大模型进行节点去重时的运行速度较慢", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-49", + "mirror_ref": "origin/mirror/upstream-pr-49", + "local_coverage": { + "number": 49, + "status": "not_safe", + "summary": "Not safe to cherry-pick: it introduces a large local graph backend without targeted regression coverage and is superseded conceptually by the broader backend-abstraction follow-up." + }, + "local_status": "not_safe", + "local_summary": "Not safe to cherry-pick: it introduces a large local graph backend without targeted regression coverage and is superseded conceptually by the broader backend-abstraction follow-up.", + "triage_status": "not_safe", + "summary": "Not safe to cherry-pick: it introduces a large local graph backend without targeted regression coverage and is superseded conceptually by the broader backend-abstraction follow-up.", + "coverage_status": "not_safe", + "coverage_summary": "Not safe to cherry-pick: it introduces a large local graph backend without targeted regression coverage and is superseded conceptually by the broader backend-abstraction follow-up.", + "local_review": { + "status": "not_safe", + "summary": "Not safe to cherry-pick: it introduces a large local graph backend without targeted regression coverage and is superseded conceptually by the broader backend-abstraction follow-up.", + "local_refs": [], + "validation": [], + "notes": null + } + }, + { + "number": 15, + "title": "fix(frontend): handle simulation failed status", + "url": "https://github.com/666ghj/MiroFish/pull/15", + "state": "open", + "created_at": "2026-01-06T06:45:39Z", + "updated_at": "2026-01-06T06:53:51Z", + "closed_at": null, + "merged_at": null, + "head": "fix/frontend-simulation-error-handling", + "head_ref_name": "fix/frontend-simulation-error-handling", + "head_sha": "6a8bc97da085f67ae0edc0e62425e0375d9f8506", + "head_repo": "tt-a1i/MiroFish", + "head_clone_url": "https://github.com/tt-a1i/MiroFish.git", + "base": "main", + "base_ref_name": "main", + "base_repo": "666ghj/MiroFish", + "draft": false, + "mergeable_state": "clean", + "labels": [], + "author": "tt-a1i", + "body_excerpt": "## Summary - Add check for `runner_status === 'failed'` in `fetchRunStatus()` to properly display error messages when simulation fails - Previously the UI would stay in \"running\" state indefinitely when simulation failed Closes #14 ## AI Assistance Disclosure I used Codex to review the changes, sanity-check the implementation against existing patterns, and help spot potential edge cases.", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": true, + "mirrored_to_origin": true, + "fork_mirror_ref": "origin/mirror/upstream-pr-15", + "mirror_ref": "origin/mirror/upstream-pr-15", + "local_coverage": { + "number": 15, + "status": "landed", + "summary": "Landed locally: Step 3 now surfaces failed simulation status instead of polling forever.", + "local_refs": [ + "frontend/src/components/Step3Simulation.vue" + ], + "validation": [ + "frontend: npm run build" + ] + }, + "local_status": "landed", + "local_summary": "Landed locally: Step 3 now surfaces failed simulation status instead of polling forever.", + "triage_status": "landed", + "summary": "Landed locally: Step 3 now surfaces failed simulation status instead of polling forever.", + "coverage_status": "landed", + "coverage_summary": "Landed locally: Step 3 now surfaces failed simulation status instead of polling forever.", + "local_review": { + "status": "landed", + "summary": "Landed locally: Step 3 now surfaces failed simulation status instead of polling forever.", + "local_refs": [ + "frontend/src/components/Step3Simulation.vue" + ], + "validation": [ + "frontend: npm run build" + ], + "notes": null + } + } + ], + "fork_remote": "origin", + "mirror_issues_repo": "ivanzud/MiroFish" +} diff --git a/docs/upstream-open-summary.md b/docs/upstream-open-summary.md new file mode 100644 index 00000000..e69c6a5a --- /dev/null +++ b/docs/upstream-open-summary.md @@ -0,0 +1,82 @@ +# Upstream Triage Snapshot + +- Repository: `666ghj/MiroFish` +- State filter: `open` +- Captured: `2026-03-12T04:03:14.445722+00:00` +- Issues: `46` total (`open=46`, `closed=0`) +- Pull requests: `40` total (`open=40`, `closed=0`) +- Mirrored in `origin`: `40` of `40` PR refs +- Mirrored in `ivanzud/MiroFish`: `46` of `46` issues +- Local issue coverage map: `docs/upstream-coverage.json` + +## Recently Updated Issues + +- #159 [open, mirror=#97] 太消耗zep了,为啥不考虑自建库呢? (enhancement) + - local coverage [tracked]: Tracked under beads issue `mirofish-zx6p`: another upstream request for a self-hosted or non-Zep graph backend is preserved locally, but implementing it safely still requires the broader graph-backend abstraction work already tracked under `mirofish-8eg` instead of wiring an ad hoc replacement into the current graph/simulation pipeline. + - zep的额度太低了,要真正进行分析,需要大量的Episode。能否考虑基于其他开源方案,重写zep部分? + - latest comment by `chrischeng192`: 你暂时可以看看[这里](https://github.com/666ghj/MiroFish/issues/56) +- #158 [open, mirror=#95] Are there any predictions that have been verified by subsequent events? (question) + - local coverage [partial]: README.md, README-EN.md, README-RU.md, README-KO.md, and README-JA.md now document a repo-native forecast verification workflow, Step 4 surfaces both the stable `report_id` and `simulation_id` with direct copy actions, the homepage history modal keeps those same references together for later review, and both views now also copy a single structured verification bundle so users can preserve the paired references in one paste. Exported Step 4 Markdown still embeds the report/simulation/graph references directly in the file header, and it now also includes stable local report paths plus a localized manual verification checklist so the saved artifact stays actionable outside the UI. Users can export that Markdown from both Step 4 and the saved-history modal or reuse the files under `backend/uploads/reports/<report_id>/` for later comparison against real-world outcomes. MiroFish still does not ship an automatic ground-truth ingester or scoring pipeline, so full backtesting remains tracked under beads issue `mirofish-gytl`. + - Awesome idea! I am wondering are there any predictions that have been verified by subsequent events? + - latest comment by `codetsang`: Not yet? Maybe you should give it a try and validate the results. BTW, this is a prediction tool, so there are many uncertainties involved. It should be used more as an analysis or decision-support tool rather than a strict predictor. +- #157 [open, mirror=#96] 如何删除不想要的记录 (question) + - local coverage [covered]: Homepage history now supports repo-native deletion of unwanted local records. The backend exposes `DELETE /api/simulation/history/<simulation_id>` to remove a simulation's persisted local directory, cascade-delete its attached local reports, and prune the project metadata when no sibling simulations remain, while refusing deletion for active runs. The history modal now exposes a localized delete action that calls that endpoint directly. + - 比如我想删除 <img width="1835" height="775" alt="Image" src="https://github.com/user-attachments/assets/12332bbc-f309-497b-a352-f0d15289042e" />这两个,怎么删除呢 +- #156 [open, mirror=#94] 能不能不要画zep图?我只要推演和角色互动 (enhancement) + - local coverage [tracked]: Tracked under beads issue `mirofish-gd5z`: upstream wants a simulation/report workflow that avoids Zep graph dependency entirely. The config-status payload and frontend backend diagnostics now expose a capability matrix that separates the direct `OPENAI_*` / Codex-compatible LLM path from Zep-gated Step 1 graph build and graph-backed Step 4 tooling, `/api/report/generate` now fails early with the same structured backend-config payload instead of launching a doomed async Step 4 task, and the frontend now also exposes a simulation-only Step 5 route plus Step 3/Step 4 CTAs so users can continue directly into role interaction without a report when only `ZEP_API_KEY` is missing. Full non-Zep simulation-only execution still needs a dedicated backend-architecture change. + - zep免费额度轻松就用完了,然后流程卡4/5在生成报告上面 +- #154 [open, mirror=#93] Profile serialization crashes when LLM returns structured bio/persona fields (no labels) + - local coverage [covered]: Profile serialization now tolerates structured LLM output instead of crashing when `bio`, `persona`, `country`, `profession`, or `interested_topics` arrive as dict/list values. `OasisAgentProfile` normalizes those mixed types at construction time and the Reddit/Twitter serializers defensively coerce them again before slicing or string replacement, so simulation preparation no longer fails during profile save with `KeyError: slice(None, 150, None)`. + - ## Summary When profile generation returns structured JSON objects for fields like `bio`, `persona`, or `country`, MiroFish can fail during profile serialization before config generation starts. ## Reproduction context Observed on a live run with: - simulation_id: `sim_e69a946b6158` - graph_id: `mirofish_a39b5f10127f4744` - entities_count: `91` - status in state file: `failed` - error in state fi… + - latest comment by `dosubot[bot]`: <!-- Greeting --> Hi @ygh1254! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the MiroFish team. <!-- Answer --> Your analysis is spot on. Looking at the code, the root cause is exactly as you described — the [`OasisAgentProfile`… +- #153 [open, mirror=#92] npm run setup:all安装时一直报 pillow` (v10.3.0) 的错 (question) + - local coverage [covered]: The current branch no longer reproduces a `pillow` build during the default `npm run setup:all` core install path. `setup:backend` now maps to a plain `uv sync` of the core graph/report/OpenAI-compatible backend dependencies, while the heavyweight simulation runtime remains behind the separate `setup:backend:simulation` entrypoint. A Windows + Python 3.13 dry-run of `uv sync --frozen` against the current lockfile does not attempt to install `pillow` at all, and the optional simulation lock now resolves `pillow==10.4.0` instead of `10.3.0`. + - Resolved 188 packages in 5.27s Built mirofish-backend @ file:///D:/MiroFish/backend x Failed to build `pillow==10.3.0` |-> The build backend returned an error `-> Call to `backend.build_wheel` failed (exit code: 1) [stderr] Traceback (most recent call last): File "<string>", line 14, in <module> requires = get_requires_for_build({}) File "C:\Users\Administrator\AppData\Local\uv\cache\builds-v0\.t… +- #150 [open, mirror=#91] Bug: Hardcoded 'reddit' platform default causes silent data loss for Twitter-only simulations (no labels) + - local coverage [covered]: Simulation data retrieval now resolves the active platform from `SimulationState` instead of silently defaulting to Reddit when Twitter/X is the only enabled platform, and `SimulationManager.get_profiles()` now reads Twitter profile CSV files correctly as well. That prevents empty profile/post responses in Twitter-only simulations even when older callers still pass the historical `reddit` default. + - ## Summary When a simulation is created with Twitter-only configuration (`enable_reddit=false`), all data retrieval APIs silently return empty results because they default to looking up Reddit data. No error is raised — the user sees an empty UI with no indication of what went wrong. ## Root Cause The platform parameter defaults to `'reddit'` in 11+ locations across the codebase. When a Twitter-o… +- #149 [open, mirror=#90] 一直卡在 Waiting for agent actions (question) + - local coverage [covered]: Step 3 now reconciles stale persisted `running` states when the worker PID is gone, and the detailed status payload exposes compact simulation-log diagnostics while waiting for the first actions. That prevents indefinite "Waiting for agent actions" polling after a dead worker and makes true startup stalls visible in the UI. + - <img width="947" height="398" alt="Image" src="https://github.com/user-attachments/assets/09b45da5-150c-4d3b-82c0-6ba2204c1743" /> + - latest comment by `dosubot[bot]`: <!-- Greeting --> Hi @jidancong! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the MiroFish team. <!-- Answer --> 这个问题通常是因为后端的 agent 动作数据没有正确生成或传递到前端。以下是几个常见原因和排查建议: **1. 检查 LLM API 配置** 最常见的原因是 [API URL 格式不正确](https://github.com… +- #146 [open, mirror=#88] [Feature Request] Add Husky for Git Hook Automated Checks (enhancement) + - local coverage [covered]: The repo now ships an opt-in, repo-native git hook workflow: `.githooks/pre-commit` runs the shared fast validation bundle, `.githooks/pre-push` runs the full validation bundle, and `npm run hooks:install` enables them without introducing a mandatory Husky/Node-only hook dependency. + - Background The current project lacks automated validation before code commits, which may lead to the following issues: 1. Committing non-compliant code (e.g., syntax errors, messy formatting); 2. Inconsistent commit messages, which is not conducive to subsequent maintenance and version tracking; 3. Inefficiency in team collaboration due to the need for manual reminders of specifications. Solution… +- #145 [open, mirror=#2] 知识图谱中存在重复实体节点 (no labels) + - local coverage [partial]: Repo-native partial mitigations are now landed locally across simulation inputs, backend graph/report/search/statistics/detail surfaces, raw graph introspection, node-edge introspection, textual tool output, both shipped graph renderers, and the visible frontend graph counters/logs: `ZepEntityReader.filter_defined_entities()` collapses obvious same-entity alias variants before simulation/profile generation, `ZepEntityReader.get_entity_with_context()` now merges alias-linked relations and related nodes for the entity-detail API, `backend/app/services/graph_builder.py` now collapses the same conservative alias pairs when serving `/api/graph/data/<graph_id>` and remaps duplicate edges to the retained node UUID, `backend/app/services/zep_tools.py` now collapses those aliases when building typed entity lists, raw node/edge introspection payloads, Panorama output, InsightForge entity/relationship summaries, QuickSearch/general search results, graph statistics, node-edge lookups, entity summaries including relations attached only to alias UUIDs, and `NodeInfo.to_text()` output, while preserving merged `alias_names` metadata so callers and downstream prompts can still see which labels were folded together. `frontend/src/views/processGraphData.js` and the shared `frontend/src/components/GraphPanel.vue` renderer now both collapse them while rendering graph data, the Process plus GraphPanel node detail drawers expose the folded non-canonical aliases via `frontend/src/components/graphAliasDetails.js`, and `frontend/src/components/graphPanelData.js` now drives deduplicated Step 1 / Process counters plus MainView refresh logs so title-prefixed duplicates such as `美国总统特朗普` vs `特朗普` no longer appear twice in the graph or its visible counts. Full graph-level persisted deduplication still remains tracked under beads issue `mirofish-975` because upstream PR #141 is not safe to cherry-pick wholesale. + - ## 问题描述 在使用 MiroFish 构建知识图谱时,Zep 会将同一现实实体识别为多个不同节点。 例如输入包含"特朗普"相关内容的文本后,图谱中会同时出现"特朗普"和 "美国总统特朗普"两个独立节点,它们各自有独立的边和关系。 这会导致: - 图谱中同一实体的信息被分散到多个节点上 - 后续的模拟推演基于不完整的实体关系进行,影响准确性 - 图谱可视化时出现冗余节点,影响可读性 ## 复现步骤 1. 准备一段包含同一人物/组织不同称呼的背景文本 2. 通过前端正常流程构建知识图谱 3. 查看生成的图谱,可以看到同一实体被拆分为多个节点 ## 截图 <img width="675" height="399" alt="Image" src="https://github.com/user-attachments/assets/593f4188-e766-46b3-9b88-25486… + +## Recently Updated Pull Requests + +- #144 [open, mergeable=clean, mirrored=yes] feat(kg): add dual-mode knowledge graph support (`feat/local-knowledge-graph` -> `main`) + - local coverage [tracked]: Tracked under beads issue `mirofish-8eg`: the dual-mode local knowledge graph branch is directionally aligned with the non-Zep backend request, but it is not safe to cherry-pick wholesale because it adds a large new adapter plus dependency stack on top of an older tree without current graph/simulation regression coverage. + - ## Summary - Add kg_adapter for dual-mode knowledge graph (cloud/local) - Support switching between Zep Cloud and local Graphiti + Neo4j - Improve entity extraction and report agent robustness - Add test_kg_adapter.py with unit tests ## Test plan - [ ] Test cloud mode with Zep Cloud - [ ] Test local mode with Graphiti + Neo4j - [ ] Run unit tests 🤖 Generated with [Claude Code](https://claude.com/… + - latest comment by `huamingjie0815`: 支持图谱的local 和cloud 双模式,local 是基于graphiti 改造,需要自己配置embedding模型 ,同时该提交增加一些功能优化,包括删除推演记录、导出报告、重新生成报告等功能,调整report_agent 的tool_call 的格式,从json改为xml 。 +- #152 [open, mergeable=clean, mirrored=yes] feat(report): Zep 命名修复与导出 Markdown 功能 (`support-pascal-and-snake-case` -> `main`) + - local coverage [landed]: Landed locally as repo-native subsets instead of a wholesale cherry-pick: ontology generation and graph build submission now normalize type names to Zep-compatible PascalCase / SCREAMING_SNAKE_CASE conventions, and TaskManager state now persists to disk so graph/report task status survives backend restarts. + - ## 概述 本 PR 包含以下改进: 1. **Zep 命名修复**:修复了 Zep API 实体/关系命名的格式校验错误(支持 PascalCase 和 snake_case)。 2. **新增功能**:报告生成步骤支持导出为 Markdown 格式,并采用了正式的 PDF 风格排版。 ## 修改详情 ### 后端 (Backend) - 在 `report_agent.py` 中改进了 `ReportManager.assemble_full_report` 方法,新增了包含 ID、模拟场景和时间戳的正式页眉。 - 添加了章节分隔符,显著提升了导出的 Markdown 文件的可读性。 ### 前端 (Frontend) - 在 `Step4Report.vue` 的报告页眉部分新增了“导出 MD”按钮。 - 在 `src/api/report.js` 中实现了 `downloadRe… +- #155 [open, mergeable=clean, mirrored=yes] chore: backend, frontend, i18n (en/zh), and Docker updates (`english-trans` -> `main`) + - local coverage [tracked]: Tracked under beads issue `mirofish-2ul1`: the combined backend/frontend/i18n/docker sweep is mirrored into `origin/mirror/upstream-pr-155`, but it rewrites 59 files on an older tree and is not safe to cherry-pick wholesale on top of the current repo-native OpenAI-compat, tests, docs, and partial i18n work. + - Made-with: Cursor +- #151 [open, mergeable=clean, mirrored=yes] Fix silent data loss when platform defaults to reddit for Twitter-only simulations (`fix/platform-default-reddit-silent-failure` -> `main`) + - local coverage [landed]: Landed locally before the upstream PR opened: Twitter-only simulations now infer the active platform instead of silently defaulting to Reddit in retrieval APIs and profile loading, matching the intent of upstream PR #151. + - ## Summary - API retrieval endpoints (`/profiles`, `/profiles/realtime`, `/posts`, `/comments`) hardcoded `'reddit'` as the default platform - When a Twitter-only simulation was run (`enable_reddit=false`), these APIs silently returned empty results because they looked for `reddit_simulation.db` / `reddit_profiles.json` which did not exist - Frontend also hardcoded `'reddit'` in Vue components an… +- #147 [open, mergeable=clean, mirrored=yes] feat: Russian localization (Русская локализация) (`russian-localization` -> `main`) + - local coverage [partial]: Safe subset landed locally: added a repo-native `README-RU.md` plus language cross-links, but the full branch is not safe to cherry-pick because it replaces large frontend/backend sections and drops current local tooling, tests, and upstream-triage assets. + - ## 🇷🇺 Russian Localization This PR adds a complete Russian translation of MiroFish: ### Changes: - **15 Vue components** — all UI labels, buttons, placeholders, error messages, and tooltips translated from Chinese to Russian - **README-RU.md** — full Russian documentation with quick start guide - Translation files are in `frontend-ru/src/` (ready to merge into `frontend/src/` when approved) - LLM… +- #141 [open, mergeable=clean, mirrored=yes] feat: add entity deduplication after graph building (`feature/entity-deduplication` -> `main`) + - local coverage [not_safe]: Not safe to cherry-pick: the entity-deduplication branch rewinds large portions of the current tree (tooling, tests, i18n, OpenAI-compatible docs/config) while adding a large graph mutation feature, so it needs a repo-native reimplementation with targeted regression coverage instead of a blind merge. + - Hi @666ghj I noticed that during graph building, Zep sometimes creates duplicate entity nodes for the same real-world entity (e.g. "特朗普" and "美国总统特朗普" appear as separate nodes). This affects the accuracy of the knowledge graph. This PR adds an automatic entity deduplication step after graph building, using name similarity pre-filtering + type compatibility check + LLM confirmation to identify and… +- #143 [open, mergeable=clean, mirrored=yes] docs: fix README alt text URL encoding (`docs/urlEncoding` -> `main`) + - local coverage [landed]: Landed locally as a repo-native docs cleanup: the Shanda logo alt text now uses the correct URL-encoded `666ghj%2FMiroFish` slug across all README variants, not just the primary Chinese README. + - ## Summary Fix the Shanda image alt text in README.md by changing 666ghj%2MiroFish to 666ghj%2FMiroFish. ## Details 666ghj%2MiroFish is not a valid URL-encoded representation, so it cannot be decoded correctly. Using 666ghj%2FMiroFish correctly encodes the slash and can be properly decoded to 666ghj/ MiroFish. ## Impact Documentation-only change. No code or runtime behavior is affected. +- #105 [open, mergeable=clean, mirrored=yes] fix: security improvements and error handling fixes (`fix/security-improvements` -> `main`) + - local coverage [landed]: Landed locally: backend security/config hardening now includes env-driven CORS controls with a localhost-only default allowlist, `DEBUG=False` by default, and generated fallback `SECRET_KEY` behavior. + - ## 问题概述 这个PR修复了项目中发现的多个安全问题和代码质量问题。 ## 安全修复 1. **硬编码的SECRET_KEY** - `backend/app/config.py` - 之前:使用硬编码的`'mirofish-secret-key'`作为默认值 - 现在:如果未设置环境变量,会生成随机密钥并发出警告 2. **DEBUG模式默认为True** - `backend/app/config.py` - 之前:`DEBUG`默认为`True` - 现在:`DEBUG`默认为`False`,生产环境更安全 3. **CORS配置允许所有来源** - `backend/app/__init__.py` - 之前:`CORS(app, resources={r"/api/*": {"origins": "*"}})` - 现在:通过环境变量`CORS_ALLOWED_ORIGINS… + - latest comment by `JasonOA888`: ## 代码审查反馈 优秀的PR!这些安全修复非常关键,特别是生产环境部署时。 ### 几个建议: 1. **SECRET_KEY随机生成** - 建议添加日志记录生成的key,方便调试但不要泄露到错误响应中 2. **CORS配置** - 考虑添加`CORS_ALLOW_METHODS`和`CORS_ALLOW_HEADERS`配置,提供更细粒度的控制 3. **error_handler.py** - 建议添加自定义异常类型,让API可以抛出特定错误而不是通用Except… +- #132 [open, mergeable=clean, mirrored=yes] docs:add simple system architecture part for README-EN.md & README.md (`docs/add-sys-architecture-part` -> `main`) + - local coverage [landed]: Landed locally: README architecture overview. + - ## PR Title docs(readme): simplify system architecture section to Layer Breakdown + Project Code Structure Tree only ## Summary This PR simplifies the **System Architecture** section in both Chinese and English README files by keeping only two high-signal sections: - **Layer Breakdown** - **Project Code Structure Tree** The previously added overall architecture diagram and related agent-intro blo… +- #131 [open, mergeable=clean, mirrored=yes] feat(graph_builder): add retry mechanism for Zep Cloud connection failures (`feat/zep-retry-mechanism` -> `main`) + - local coverage [landed]: Safe subset landed locally: transient Zep failures now retry with bounded backoff. + - ## Description Adds automatic retry mechanism to handle transient network errors when connecting to Zep Cloud API. This prevents graph build failures caused by temporary connection issues such as "Connection reset by peer" (errno 54). The retry logic uses exponential backoff (2s, 4s, 6s) and provides detailed progress feedback to users. ## Changes - Added retry logic (max 3 attempts) to `create_g… diff --git a/docs/upstream-triage.md b/docs/upstream-triage.md new file mode 100644 index 00000000..64004252 --- /dev/null +++ b/docs/upstream-triage.md @@ -0,0 +1,660 @@ +# Upstream Triage + +Last refreshed: `2026-03-12` + +## Current focus + +- Keep reusable local snapshots of `666ghj/MiroFish` open and full issue/PR state. +- Keep those snapshots triage-friendly by preserving compact issue/PR body excerpts plus recent comment previews, not just counts and branch metadata. +- Keep fork visibility current by annotating upstream snapshots with mirror status and pushing missing clean PR refs into `origin` when they are still review-relevant. +- Land small, low-risk upstream fixes before considering larger feature branches. +- Keep OpenAI-compatible backend support verified in both code paths and docs while reviewing the remaining open PR queue. +- Keep the remaining clean PR queue pruned by recording when a candidate is already superseded locally or no longer safe after later backend/runtime changes. + +## Reviewed This Pass + +- Upstream intake refreshed live again on March 12, 2026 at `2026-03-12T04:00:38.517756+00:00` for the open queue and `2026-03-12T04:00:52.898453+00:00` for the full history snapshot. The machine-readable summaries remain current at `46` open issues / `40` open PRs and `96` total issues / `54` total PRs, with all `46` upstream issues still mirrored into `ivanzud/MiroFish` and all `40` open PR heads still visible in `origin`. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining open PR queue is still `22` `landed`, `7` `superseded`, `6` `not_safe`, `2` `tracked`, `2` `partial`, and `1` `covered`, so this pass stayed on repo-native follow-up work instead of a cherry-pick. +- Upstream issue `#158` has another repo-native docs refinement now: `README-RU.md`, `README-KO.md`, and `README-JA.md` now match the Chinese/English forecast-verification guidance, so the full translated README set explains how to preserve `report_id` / `simulation_id`, export Markdown evidence, and distinguish the current manual review path from true automated backtesting. +- Upstream intake refreshed live again on March 12, 2026 at `2026-03-12T03:56:11.639756+00:00` for the open queue and `2026-03-12T03:56:19.838272+00:00` for the full history snapshot. The machine-readable summaries remain current at `46` open issues / `40` open PRs and `96` total issues / `54` total PRs, with all `46` upstream issues still mirrored into `ivanzud/MiroFish` and all `40` open PR heads still visible in `origin`. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining open PR queue is unchanged at `6` `not_safe`, `2` `tracked`, `2` `partial`, and `7` `superseded`, so this pass stayed in the repo-native issue queue. +- Upstream issue `#158` has another repo-native verification-artifact refinement now: `backend/app/services/report_agent.py` adds stable local report-folder and Markdown-path references plus a localized manual verification checklist directly to exported reports, so saved forecast evidence remains actionable even outside the UI/history modal. Focused regression coverage lives in `backend/tests/test_report_agent.py`, and validation passed with `cd backend && uv run pytest -q tests/test_report_agent.py -k "reference_block or embeds_reference_block"`, `python3 -m compileall backend/app/services/report_agent.py backend/tests/test_report_agent.py`, and `bash ./scripts/test_backend_lite.sh`. +- Upstream intake refreshed live again on March 12, 2026 at `2026-03-12T03:48:39.226899+00:00` for the open queue and `2026-03-12T03:48:49.064013+00:00` for the full history snapshot. The machine-readable summaries remain current at `46` open issues / `40` open PRs and `96` total issues / `54` total PRs, with all `46` upstream issues still mirrored into `ivanzud/MiroFish` and all `40` open PR heads still visible in `origin`. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining open PR queue is unchanged at `6` `not_safe`, `2` `tracked`, `2` `partial`, and `7` `superseded`, so this pass shifted back into the repo-native issue queue. +- Upstream issue `#158` now has another repo-native verification UX refinement: `frontend/src/components/Step4Report.vue` and `frontend/src/components/HistoryDatabase.vue` both expose a single copyable verification bundle built by `frontend/src/components/verificationBundle.js`, so users can preserve the paired `simulation_id` / `report_id` references in one paste instead of copying multiple fields by hand while comparing a forecast against later real-world outcomes. Focused regression coverage now lives in `frontend/tests/verificationBundle.test.mjs`, and the verification flow still passes `npm --prefix frontend test` plus `npm --prefix frontend run build`. + +- Upstream intake refreshed live again on March 12, 2026 at `2026-03-12T03:42:49.720320+00:00` for the open queue and `2026-03-12T03:43:08.700719+00:00` for the full history snapshot. The machine-readable summaries remain current at `46` open issues / `40` open PRs and `96` total issues / `54` total PRs, with all `46` upstream issues still mirrored into `ivanzud/MiroFish` and all `40` open PR heads still visible in `origin`. +- Revalidated the direct Codex/OpenAI-compatible backend path on March 12, 2026 in a clean shell using only `OPENAI_API_KEY`, `OPENAI_API_BASE_URL=https://codex.example.test/v1`, `OPENAI_MODEL=gpt-4.1-mini`, and `ZEP_API_KEY`. `npm run check:backend-config -- --compact` still reports `llm.backend_mode = openai_compatible`, `summary.capabilities.direct_llm.ready = true`, and the expected step-level capability split where Step 1 graph build and Step 4 graph-backed report tools remain Zep-gated. +- Another safe-merge review after the refreshed snapshot still did not expose a new clean upstream PR to adopt. The remaining open PR queue is unchanged at `6` `not_safe`, `2` `tracked`, `2` `partial`, and `7` `superseded`, so the next actionable work remains repo-native follow-ups rather than a branch cherry-pick. + +- Added translated docs parity for the direct Codex/OpenAI-compatible backend path on March 12, 2026 via beads issue `mirofish-7aht`. `README-RU.md`, `README-KO.md`, and `README-JA.md` now explain the `summary.capabilities` matrix from `npm run backend:local` / `/api/graph/config/status`, so the translated setup guides also distinguish the direct `direct_llm` path from the Zep-gated Step 1 / Step 4 capabilities and the existing-simulation Step 5 interaction path. +- Upstream intake refreshed live again on March 12, 2026 at `2026-03-12T03:32:51.309441+00:00` for the open queue and `2026-03-12T03:33:03.480804+00:00` for the full history snapshot. The machine-readable summaries remain current at `46` open issues / `40` open PRs and `96` total issues / `54` total PRs, with all `46` upstream issues mirrored into `ivanzud/MiroFish` and all `40` open PR heads still visible in `origin`. +- Direct OpenAI-compatible backend verification passed again on March 12, 2026 in a clean shell using only `OPENAI_API_KEY`, `OPENAI_API_BASE_URL=https://codex.example.test/v1`, `OPENAI_MODEL=gpt-4.1-mini`, and `ZEP_API_KEY`. `npm run check:backend-config` still reports `llm.backend_mode = openai_compatible`, resolves the active source aliases to `OPENAI_*`, and emits only the expected generated-`SECRET_KEY` warning when `SECRET_KEY` is unset. +- Another safe-merge review after the refreshed snapshot still did not expose a new clean upstream PR to adopt. The remaining non-landed open PR queue is unchanged at `6` `not_safe`, `2` `tracked`, `2` `partial`, and `7` `superseded`, so the next actionable work remains repo-native follow-ups rather than a branch cherry-pick. + +- Upstream intake refreshed live again on March 12, 2026 at `2026-03-12T03:21:59.118998+00:00` for the open queue and `2026-03-12T03:22:08.961464+00:00` for the full history snapshot. The machine-readable summaries are now current at `46` open issues / `40` open PRs and `96` total issues / `54` total PRs, with all `46` upstream issues mirrored into `ivanzud/MiroFish` and all `40` open PR heads still visible in `origin`. +- The sync contract is stronger now: `scripts/sync_upstream_github.py` emits a first-class `local_review` object for pull requests in both fresh and cached snapshot refreshes, so downstream machine-readable consumers can see whether each open PR was `landed`, `superseded`, `tracked`, `partial`, `covered`, or `not_safe` without inferring that from loose aliases. The refreshed open PR queue currently breaks down to `22` landed, `7` superseded, `6` not safe, `2` tracked, `2` partial, and `1` covered. +- New upstream issue `#159` ("太消耗zep了,为啥不考虑自建库呢?") is now mirrored into fork issue `#97` and tracked locally under beads issue `mirofish-zx6p`, linked back to the broader non-Zep backend abstraction work in `mirofish-8eg`. +- Upstream PR `#155` was re-reviewed against `origin/mirror/upstream-pr-155` and remains unsafe to cherry-pick: it is still a broad older-tree rewrite across backend/frontend/i18n/docker surfaces rather than a low-risk incremental subset. + +- Upstream intake refreshed live again on March 12, 2026 at `2026-03-12T03:07:19.205256+00:00` for the open queue and `2026-03-12T03:07:28.856198+00:00` for the full history snapshot. The machine-readable summaries remain current at `45` open issues / `40` open PRs and `95` total issues / `54` total PRs, with all `45` upstream issues mirrored into `ivanzud/MiroFish` and all `40` open PR heads still visible in `origin`. +- Upstream issue `#156` has another repo-native mitigation now: the frontend supports a simulation-only Step 5 route via `frontend/src/components/interactionRoute.js`, `frontend/src/router/index.js`, and `frontend/src/views/InteractionView.vue`, so users on direct `OPENAI_*` / Codex-compatible backends can continue into Step 5 without a `report_id` when Step 4 is blocked on missing `ZEP_API_KEY`. `frontend/src/components/Step3Simulation.vue` now exposes that shortcut immediately when report preflight blocks Step 4, `frontend/src/components/Step4Report.vue` offers the same escape hatch from failed-report states, and `frontend/src/components/Step5Interaction.vue` now degrades cleanly into interaction-only mode without pretending a Step 4 report exists. Focused regression coverage now lives in `frontend/tests/interactionRoute.test.mjs`. +- Validation for this pass passed with `npm --prefix frontend test` and `npm --prefix frontend run build`. +- Another safe-merge review after this pass still did not expose a new clean upstream PR to adopt; the remaining non-landed open PR queue is still the tracked/unsafe/superseded set already recorded below. + +- Upstream intake refreshed live again on March 12, 2026 at `2026-03-12T03:04:20.094565+00:00` for the open queue and `2026-03-12T03:04:30.129608+00:00` for the full history snapshot. The machine-readable summaries remain current at `45` open issues / `40` open PRs and `95` total issues / `54` total PRs, with all `45` upstream issues mirrored into `ivanzud/MiroFish` and all `40` open PR heads still visible in `origin`. +- Upstream issue `#9` has another repo-native partial recovery mitigation now: `frontend/src/components/Step5Interaction.vue` reuses the Step 3 replay/restart route via `frontend/src/components/step5Recovery.js`, so when the Step 5 interview environment is offline but the simulation still has replayable state the workspace exposes a direct recovery card instead of forcing users to navigate back manually. Focused regression coverage now lives in `frontend/tests/step5Recovery.test.mjs`. +- Validation for this pass passed with `npm --prefix frontend test` and `npm --prefix frontend run build`. +- Another safe-merge review after this pass still did not expose a new clean upstream PR to adopt; the remaining non-landed open PR queue is still the tracked/unsafe/superseded set already recorded below. + +- Upstream intake refreshed live again on March 12, 2026 at `2026-03-12T02:53:47.440734+00:00` for the open queue and `2026-03-12T02:53:54.941693+00:00` for the full history snapshot. The machine-readable summaries remain current at `45` open issues / `40` open PRs and `95` total issues / `54` total PRs, with all `45` upstream issues mirrored into `ivanzud/MiroFish` and all `40` open PR heads still visible in `origin`. +- Upstream issue `#158` has another repo-native verification UX refinement now: `frontend/src/components/Step4Report.vue` and `frontend/src/components/Step5Interaction.vue` no longer show a fake placeholder report ID when no real `report_id` exists, and instead reuse the localized unavailable-reference copy so users do not preserve a bogus artifact identifier while reviewing or exporting forecasts. Focused regression coverage now lives in `frontend/tests/reportReferences.test.mjs`. +- Validation for this pass passed with `npm --prefix frontend test` and `npm --prefix frontend run build`. +- Another safe-merge review after this pass still did not expose a new clean upstream PR to adopt; the remaining non-landed open PR queue is still the tracked/unsafe/superseded set already recorded below. + +- Upstream issue `#9` has another repo-native partial recovery mitigation now: `frontend/src/components/Step2EnvSetup.vue` surfaces a Step 3 recovery card whenever the current simulation already has saved run state, so after fixing quota/API-key problems users can reopen the replay-only Step 3 route directly from Step 2 instead of finding the same action through the history modal first. The state mapping lives in `frontend/src/components/step2Recovery.js`. +- Validation for this pass passed with `npm --prefix frontend test` and `npm --prefix frontend run build`. +- Another safe-merge review after this pass still did not expose a new clean upstream PR to adopt; the remaining non-landed open PR queue is still the tracked/unsafe/superseded set already recorded below. + +- Upstream issue `#156` has another repo-native partial mitigation now: `backend/app/api/report.py` reuses the same structured backend config payload as the graph endpoints, so `/api/report/generate` fails fast with a non-sensitive `503` when Step 4 is impossible under a direct `OPENAI_*` / Codex-compatible setup without `ZEP_API_KEY`, instead of launching an async report task that only fails later. +- Validation for this pass passed with `uv run --project backend pytest -q backend/tests/test_report_api_i18n.py`, `python3 -m compileall backend/app/api/report.py backend/tests/test_report_api_i18n.py`, and `bash ./scripts/test_backend_lite.sh` (`173 passed`). +- Another safe-merge review in this pass still did not expose a new clean upstream PR to adopt; the remaining non-landed queue is still the tracked/unsafe/superseded set already recorded below. + +- Upstream intake was revalidated again against the current local snapshots (`docs/upstream-open-state.json` at `2026-03-12T02:33:12.068430+00:00`, `docs/upstream-all-state.json` at `2026-03-12T02:33:27.052491+00:00`). Fork visibility still covers all `45` open upstream issues and all `40` open PR heads. +- The optional simulation dependency follow-up is stronger now. `backend/uv.lock` has been re-resolved so the simulation extra no longer carries `pillow==10.3.0`; it now locks `pillow==10.4.0` while preserving the existing split where the default backend install path avoids Pillow entirely. +- Validation for this pass passed with `cd backend && uv sync --extra simulation --frozen --dry-run` and `bash ./scripts/test_backend_lite.sh` (`173 passed`). +- Another safe-merge review after re-checking PRs `#151`, `#143`, and `#82` did not expose a new branch to cherry-pick. `#151` and `#143` are already landed repo-natively, while `#82` remains a repo-native dependency/lock reconciliation rather than a clean direct cherry-pick because this branch intentionally diverged from the old default dependency layout. + +- Upstream intake refreshed live again on March 12, 2026 at `2026-03-12T02:30:20.866506+00:00` for the open queue and `2026-03-12T02:30:42.374417+00:00` for the full history snapshot. The machine-readable summaries remain current at `45` open issues / `40` open PRs and `95` total issues / `54` total PRs, with all `45` upstream issues mirrored into `ivanzud/MiroFish` and all `40` open PR heads still visible in `origin`. +- Upstream issue `#158` now has another repo-native partial verification improvement: `backend/app/services/report_agent.py` now embeds the stable `report_id`, `simulation_id`, `graph_id`, generation timestamp, and simulation requirement directly into the exported `full_report.md` header, so saved Markdown remains self-identifying even after it leaves the Step 4/history UI. Focused regression coverage now lives in `backend/tests/test_report_agent.py`. +- Validation for this pass passed with `uv run --project backend pytest -q backend/tests/test_report_agent.py backend/tests/test_report_api_i18n.py`, `python3 -m compileall backend/app/services/report_agent.py backend/tests/test_report_agent.py backend/tests/test_report_api_i18n.py`, and `bash ./scripts/test_backend_lite.sh` (`173 passed`). +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PR queue is still intentionally partial localization (`#119`, `#147`) plus tracked, unsafe, or superseded branches (`#155`, `#141`, `#144`, `#118`, `#114`, `#108`, `#102`, `#101`, `#100`, `#87`, `#86`, `#82`, `#72`, `#70`, `#49`, `#38`). + +- Upstream intake artifacts were repaired and regenerated after a merge left conflict markers in the checked-in open/full snapshot files and summaries. A fresh live refresh at `2026-03-12T02:06:41.331237+00:00` (open) and `2026-03-12T02:06:53.174890+00:00` (all) restored machine-readable state to `45` open issues / `40` open PRs and `95` total issues / `54` total PRs, with all open upstream issues mirrored into `ivanzud/MiroFish` and all reviewed PR refs visible in `origin`, including the newly surfaced mirror for upstream PR `#119`. +- Upstream issue `#158` now has a slightly stronger repo-native partial answer: beyond the existing README docs, `frontend/src/components/Step4Report.vue` now surfaces both the stable `report_id` and `simulation_id` in the Step 4 header plus an explicit reminder to keep those IDs with exported Markdown or the saved history entry for later outcome comparison. Automatic backtesting or ground-truth scoring is still deferred under beads issue `mirofish-gytl`. +- Another safe-merge review after the repaired refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PR queue is still intentionally partial localization (`#119`, `#147`) plus tracked, unsafe, or superseded branches (`#155`, `#141`, `#144`, `#118`, `#114`, `#108`, `#102`, `#101`, `#100`, `#87`, `#86`, `#82`, `#72`, `#70`, `#49`, `#38`). + +- Upstream intake refreshed live again on March 12, 2026 at `2026-03-12T02:03:20.142578+00:00` for the open queue and `2026-03-12T02:03:29.757575+00:00` for the full history snapshot. The machine-readable summaries remain current at `45` open issues / `40` open PRs and `95` total issues / `54` total PRs, with all `45` upstream issues mirrored into `ivanzud/MiroFish` and all `54` mirrored upstream PR heads still visible in `origin`. +- Upstream issue `#158` now has a concrete repo-native partial answer instead of tracker-only status. `README.md` and `README-EN.md` now explain how to preserve the relevant `simulation_id` / `report_id`, export or retain the Step 4 Markdown report under `backend/uploads/reports/<report_id>/`, and compare that saved evidence against later real-world outcomes. Full automatic backtesting or ground-truth scoring is still deferred under beads issue `mirofish-gytl`. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PR queue is unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe or superseded branches (`#141`, `#144`, `#118`, `#114`, `#108`, `#102`, `#101`, `#100`, `#87`, `#86`, `#70`, `#38`, `#49`, `#72`, `#155`). + +- Upstream intake refreshed live again on March 12, 2026 at `2026-03-12T02:00:36.852174+00:00` for the open queue and `2026-03-12T02:00:45.014774+00:00` for the full history snapshot. The machine-readable summaries remain current at `45` open issues / `40` open PRs and `95` total issues / `54` total PRs, with all `45` upstream issues mirrored into `ivanzud/MiroFish` and all `54` mirrored upstream PR heads still visible in `origin`. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PR queue is unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe or superseded branches (`#141`, `#144`, `#118`, `#114`, `#108`, `#102`, `#101`, `#100`, `#87`, `#86`, `#70`, `#38`, `#49`, `#72`, `#155`). +- Direct OpenAI-compatible backend verification passed again in a clean shell using only `OPENAI_API_KEY`, `OPENAI_API_BASE_URL=https://codex.example.test/v1`, `OPENAI_MODEL=gpt-4.1-mini`, `ZEP_API_KEY`, and `SECRET_KEY`. `npm run check:backend-config -- --compact` still reports `llm.backend_mode = openai_compatible`, keeps `summary.capabilities.direct_llm.ready = true`, and shows the expected Zep boundary for graph build/report tooling. +- Lightweight backend validation also passed again in this pass with `bash ./scripts/test_backend_lite.sh` (`171 passed`), so the refreshed upstream queue still does not expose a new reproducible low-risk code issue beyond the existing tracked design-sized gaps (`mirofish-975`, `mirofish-qoo`, `mirofish-as6`, `mirofish-8eg`, `mirofish-77h`, `mirofish-3j8`, `mirofish-hj9`, `mirofish-pfbl`, `mirofish-2ul1`, `mirofish-gd5z`, `mirofish-gytl`). + +- Upstream intake refreshed live again on March 12, 2026 at `2026-03-12T01:53:22.345564+00:00` for the open queue and `2026-03-12T01:53:29.374680+00:00` for the full history snapshot. The machine-readable summaries now show `45` open issues / `40` open PRs and `95` total issues / `54` total PRs, with all `45` upstream issues mirrored into `ivanzud/MiroFish` and all `54` mirrored upstream PR heads still visible in `origin`. +- Upstream issue `#157` ("如何删除不想要的记录") is now covered locally instead of remaining unanswered. The backend exposes `DELETE /api/simulation/history/<simulation_id>` to delete a simulation's local history assets, cascade-delete attached local reports, and prune the project metadata when no sibling simulations remain, while the homepage history modal in `frontend/src/components/HistoryDatabase.vue` now offers a localized delete action wired to that endpoint. Verified with `cd backend && uv run pytest -q tests/test_simulation_api_i18n.py`, `bash ./scripts/test_backend_lite.sh` (`171 passed`), and `npm --prefix frontend run build`. +- The two newly surfaced upstream questions are now both tracked in beads-backed local memory. `#157` is implemented under `mirofish-dmw2`, and `#158` is preserved as the new follow-up `mirofish-gytl` because the repo still lacks an explicit forecast-verification workflow or examples. +- Repo-native diagnostics are more explicit for upstream issue `#156`: `backend/app/config.py` now publishes a machine-readable `summary.capabilities` matrix in config-status, `frontend/src/components/apiConfigDiagnostics.js` renders those capability boundaries in the backend diagnostics card, and `README.md` plus `README-EN.md` document how to interpret the direct-LLM-vs-Zep split. Verified with `cd backend && uv run pytest -q tests/test_config.py tests/test_print_config_status.py`, `npm --prefix frontend test -- --runInBand apiConfigDiagnostics.test.mjs`, `npm --prefix frontend run build`, clean-shell `npm run check:backend-config -- --compact` with and without `ZEP_API_KEY`, and `bash ./scripts/test_backend_lite.sh`. +- Upstream intake refreshed live again on March 12, 2026 at `2026-03-12T01:44:33.827213+00:00` for the open queue and `2026-03-12T01:44:43.316873+00:00` for the full history snapshot. The machine-readable summaries now show `43` open issues / `40` open PRs and `93` total issues / `54` total PRs, with all `43` upstream issues mirrored into `ivanzud/MiroFish` and all `54` mirrored upstream PR heads still visible in `origin`. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PR queue is unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe or superseded branches (`#141`, `#144`, `#118`, `#114`, `#108`, `#102`, `#101`, `#100`, `#87`, `#86`, `#70`, `#38`, `#49`, `#72`, `#155`). +- Direct OpenAI-compatible backend verification passed again in a clean shell using only `OPENAI_API_KEY`, `OPENAI_API_BASE_URL=https://codex.example.test/v1`, `OPENAI_MODEL=gpt-4.1-mini`, `ZEP_API_KEY`, and `SECRET_KEY`. `npm run check:backend-config -- --compact` still reports `llm.backend_mode = openai_compatible`, resolves the expected direct `OPENAI_*` alias sources, and reports no validation warnings or errors, while `bash ./scripts/test_backend_lite.sh` passed again (`167 passed`). +- The non-Zep boundary is a little clearer after this pass: Step 5 interview/runtime APIs already operate without `ZEP_API_KEY` once a simulation environment exists, so the remaining hard dependency for upstream issue `#156` is Step 1 graph build/read plus Step 4 graph-backed report tooling rather than the direct `OPENAI_*` / Codex-compatible backend path itself. + +- Upstream intake refreshed live again on March 12, 2026 at `2026-03-12T01:40:26.709892+00:00` for the open queue and `2026-03-12T01:40:37.772431+00:00` for the full history snapshot. The machine-readable summaries now show `43` open issues / `40` open PRs and `93` total issues / `54` total PRs, with all `43` upstream issues mirrored into `ivanzud/MiroFish` and all `54` mirrored upstream PR heads still visible in `origin`. +- The new upstream issue `#156` ("能不能不要画zep图?我只要推演和角色互动") is now tracked locally in beads as `mirofish-gd5z` instead of remaining unclassified. It is related to the broader non-Zep backend request, but the actual remaining gap is a simulation/report workflow that does not depend on Zep-backed graph data rather than another direct-LLM wiring bug. +- Repo-native UX improved around that boundary in this pass: the frontend backend-diagnostics card (`frontend/src/components/apiConfigDiagnostics.js`) now keeps the direct `OPENAI_*` / Codex-compatible LLM path marked as ready when the only missing validation item is `ZEP_API_KEY`, while showing an explicit note that Step 1 graph build and graph-backed report tooling still require Zep until a non-Zep backend lands. Covered by `frontend/tests/apiConfigDiagnostics.test.mjs` and validated with `npm --prefix frontend test -- --runInBand apiConfigDiagnostics.test.mjs` plus `npm --prefix frontend run build`. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PR queue is unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe or superseded branches (`#141`, `#144`, `#118`, `#114`, `#108`, `#102`, `#101`, `#100`, `#87`, `#86`, `#70`, `#38`, `#49`, `#72`, `#155`). +- A new docs-only beads child, `mirofish-6f45`, now tracks Russian README parity for the direct backend path and runtime caveats. `README-RU.md` now includes the repo-native optional simulation install split, the Python 3.13+/Rust caveat for `npm run setup:backend:simulation`, first-run sizing and timeout guidance, browser-refresh/runtime-session behavior, and the lightweight `npm run test:backend:lite` validation path, so the localized guide better matches the current direct OpenAI-compatible backend workflow without importing the unsafe full upstream Russian-localization branch. +- Direct OpenAI-compatible backend verification passed again in a clean shell using only `OPENAI_API_KEY`, `OPENAI_API_BASE_URL=https://codex.example.test/v1`, `OPENAI_MODEL=gpt-4.1-mini`, `ZEP_API_KEY`, and `SECRET_KEY`. `npm run check:backend-config -- --compact` still reports `llm.backend_mode = openai_compatible`, resolves the expected direct `OPENAI_*` alias sources, and reports no validation warnings or errors. +- Lightweight backend validation also passed again in this pass with `bash ./scripts/test_backend_lite.sh` (`167 passed`). +- A full live upstream refresh was attempted again on March 12, 2026, but GitHub blocked the unauthenticated/direct HTTP fallback with `HTTP 403 rate limit exceeded` after `gh api` itself was already rate-limited. Because the repo already contains fresher local snapshots from `2026-03-12T01:15:20.581430+00:00`, this pass continued with repo-native work instead of mutating the stale intake artifacts blindly. The next refresh attempt needs a valid `GITHUB_TOKEN` / `GH_TOKEN` or a later rate-limit window. + +- Upstream intake refreshed again on March 12, 2026 at `2026-03-12T01:15:10.701153+00:00` for the open queue and `2026-03-12T01:15:20.581430+00:00` for the full history snapshot. The machine-readable summaries remain current at `42` open issues / `40` open PRs and `92` total issues / `54` total PRs, with all `42` upstream issues mirrored into `ivanzud/MiroFish` and all `54` mirrored upstream PR heads still visible in `origin`. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PR queue is unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe or superseded branches (`#141`, `#144`, `#118`, `#114`, `#108`, `#102`, `#101`, `#100`, `#87`, `#86`, `#70`, `#38`, `#49`, `#72`, `#155`). +- Direct OpenAI-compatible backend verification remains intact on this branch: the documented `OPENAI_API_KEY` + `OPENAI_API_BASE_URL` + `OPENAI_MODEL` path is still the supported direct backend route, and another audit of the remaining partial issue queue did not surface a new reproducible low-risk child issue beyond the existing tracked design-sized gaps (`mirofish-975`, `mirofish-qoo`, `mirofish-as6`, `mirofish-8eg`, `mirofish-77h`, `mirofish-3j8`, `mirofish-hj9`, `mirofish-pfbl`, `mirofish-2ul1`). + +- Upstream intake refreshed again on March 12, 2026 at `2026-03-12T01:11:31.307136+00:00` for the open queue and `2026-03-12T01:11:41.422859+00:00` for the full history snapshot. The machine-readable summaries remain current at `42` open issues / `40` open PRs and `92` total issues / `54` total PRs, with all `42` upstream issues mirrored into `ivanzud/MiroFish` and all `54` mirrored upstream PR heads still visible in `origin`. +- Direct OpenAI-compatible backend verification passed again in a clean shell using only `OPENAI_API_KEY`, `OPENAI_API_BASE_URL=https://codex.example.test/v1`, `OPENAI_MODEL=gpt-4.1-mini`, `ZEP_API_KEY`, and `SECRET_KEY`. `npm run check:backend-config -- --compact` still reports `llm.backend_mode = openai_compatible`, resolves the expected direct `OPENAI_*` alias sources, and reports no validation warnings or errors. +- Lightweight backend validation also passed again in this pass with `bash ./scripts/test_backend_lite.sh` (`167 passed`). +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt, and another audit of the remaining partial issue queue still did not surface a new reproducible low-risk child issue beyond the existing tracked design-sized gaps (`mirofish-975`, `mirofish-qoo`, `mirofish-as6`, `mirofish-8eg`, `mirofish-77h`, `mirofish-3j8`, `mirofish-hj9`, `mirofish-pfbl`, `mirofish-2ul1`). + +- Upstream intake refreshed again on March 12, 2026 at `2026-03-12T01:08:48.541765+00:00` for the open queue and `2026-03-12T01:09:08.607340+00:00` for the full history snapshot. The machine-readable summaries remain current at `42` open issues / `40` open PRs and `92` total issues / `54` total PRs, with all `42` upstream issues mirrored into `ivanzud/MiroFish` and all `54` mirrored upstream PR heads still visible in `origin`. +- This pass also fixed a repo-native upstream-intake regression: `scripts/refresh_upstream_snapshots.sh` once again accepts `--fork-remote` and `--mirror-issues-repo`, matching the flags it already forwards internally to `scripts/sync_upstream_github.py`. That restores explicit fork-mirror control for scripted refreshes and stale session recovery instead of failing fast with `unsupported option '--fork-remote'`. +- Regression coverage for the wrapper now includes fork-mirror override forwarding in `tests/test_refresh_upstream_snapshots.py`, and end-to-end validation passed via `python3 -m unittest tests.test_refresh_upstream_snapshots`, a real `bash ./scripts/refresh_upstream_snapshots.sh --repo 666ghj/MiroFish --fork-remote origin --mirror-issues-repo ivanzud/MiroFish --force-refresh`, `env -i ... npm run check:backend-config -- --compact`, and `bash ./scripts/test_backend_lite.sh` (`167 passed`). +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PR queue is unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe or superseded branches (`#141`, `#144`, `#118`, `#114`, `#108`, `#102`, `#101`, `#100`, `#87`, `#86`, `#70`, `#38`, `#49`, `#72`, `#155`). + +- Upstream intake refreshed again on March 12, 2026 at `2026-03-12T01:04:34.725683+00:00` for the open queue and `2026-03-12T01:04:44.821132+00:00` for the full history snapshot. The machine-readable summaries remain current at `42` open issues / `40` open PRs and `92` total issues / `54` total PRs, with all `42` upstream issues mirrored into `ivanzud/MiroFish` and all `54` mirrored upstream PR heads still visible in `origin`. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PR queue is unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe or superseded branches (`#141`, `#144`, `#118`, `#114`, `#108`, `#102`, `#101`, `#100`, `#87`, `#86`, `#70`, `#38`, `#49`, `#72`, `#155`). +- Direct OpenAI-compatible backend verification passed again in a clean shell with only `OPENAI_API_KEY`, `OPENAI_API_BASE_URL=https://codex.example.test/v1`, `OPENAI_MODEL=gpt-4.1-mini`, and `ZEP_API_KEY` set. `npm run check:backend-config -- --compact` still reports `llm.backend_mode = openai_compatible`, resolves `summary.llm.sources` to the expected direct `OPENAI_*` aliases, and emits only the expected generated-`SECRET_KEY` warning when `SECRET_KEY` is unset. +- A follow-up audit of the remaining partial issue queue still did not surface a new reproducible low-risk child issue beyond the existing tracked design-sized gaps (`mirofish-975`, `mirofish-qoo`, `mirofish-as6`, `mirofish-8eg`, `mirofish-77h`, `mirofish-3j8`, `mirofish-hj9`, `mirofish-pfbl`, `mirofish-2ul1`). + +- Upstream intake refreshed again on March 12, 2026 at `2026-03-12T01:00:12.211069+00:00` for the open queue and `2026-03-12T01:00:22.413094+00:00` for the full history snapshot. The machine-readable summaries remain current at `42` open issues / `40` open PRs and `92` total issues / `54` total PRs, with all `42` upstream issues mirrored into `ivanzud/MiroFish` and all `54` mirrored upstream PR heads still visible in `origin`. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PR queue is unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe or superseded branches (`#141`, `#144`, `#118`, `#114`, `#108`, `#102`, `#101`, `#100`, `#87`, `#86`, `#70`, `#38`, `#49`, `#72`, `#155`). +- Direct OpenAI-compatible backend verification passed again in a clean shell with only `OPENAI_API_KEY`, `OPENAI_API_BASE_URL=https://codex.example.test/v1`, `OPENAI_MODEL=gpt-4.1-mini`, and `ZEP_API_KEY` set. `npm run check:backend-config -- --compact` still reports `llm.backend_mode = openai_compatible`, resolves `summary.llm.sources` to the expected direct `OPENAI_*` aliases, and emits only the expected generated-`SECRET_KEY` warning when `SECRET_KEY` is unset. +- Lightweight backend validation also passed again in this pass with `bash ./scripts/test_backend_lite.sh` (`167 passed`). A follow-up audit of the remaining partial localization/resume umbrellas still did not surface a new reproducible low-risk child issue beyond the existing tracked design-sized gaps (`mirofish-975`, `mirofish-qoo`, `mirofish-as6`, `mirofish-8eg`, `mirofish-77h`, `mirofish-3j8`, `mirofish-hj9`, `mirofish-pfbl`, `mirofish-2ul1`). + +- Upstream intake refreshed again on March 12, 2026 at `2026-03-12T00:53:00.756969+00:00` for the open queue and `2026-03-12T00:53:10.909014+00:00` for the full history snapshot. The machine-readable summaries remain current at `42` open issues / `40` open PRs and `92` total issues / `54` total PRs, with all `42` upstream issues mirrored into `ivanzud/MiroFish` and all `54` mirrored upstream PR heads still visible in `origin`. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PR queue is unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe or superseded branches (`#141`, `#144`, `#118`, `#114`, `#108`, `#102`, `#101`, `#100`, `#87`, `#86`, `#70`, `#38`, `#49`, `#72`, `#155`). +- Direct OpenAI-compatible backend verification passed again in a clean shell with only `OPENAI_API_KEY`, `OPENAI_API_BASE_URL=https://codex.example.test/v1`, `OPENAI_MODEL=gpt-4.1-mini`, and `ZEP_API_KEY` set. `npm run check:backend-config -- --compact` still reports `llm.backend_mode = openai_compatible`, resolves `summary.llm.sources` to the expected direct `OPENAI_*` aliases, and emits only the expected generated-`SECRET_KEY` warning when `SECRET_KEY` is unset. +- Lightweight backend validation also passed again in this pass with `bash ./scripts/test_backend_lite.sh` (`166 passed`), so the refreshed upstream queue still does not expose a new reproducible low-risk code issue beyond the existing tracked design-sized gaps (`mirofish-975`, `mirofish-qoo`, `mirofish-as6`, `mirofish-8eg`, `mirofish-77h`, `mirofish-3j8`, `mirofish-hj9`, `mirofish-pfbl`, `mirofish-2ul1`). + +- Upstream intake refreshed again on March 12, 2026 at `2026-03-12T00:50:17.224935+00:00` for the open queue and `2026-03-12T00:50:35.805595+00:00` for the full history snapshot. The machine-readable summaries are current at `42` open issues / `40` open PRs and `92` total issues / `54` total PRs, with all `42` upstream issues mirrored into `ivanzud/MiroFish` and all `54` mirrored upstream PR heads now visible in `origin`. +- Upstream PR `#155` (`english-trans`) is now mirrored automatically into `origin/mirror/upstream-pr-155` as part of the normal refresh flow. A fresh diff review shows it rewrites `59` files (`3840` insertions / `8428` deletions) across backend/frontend/i18n/docker surfaces on an older tree, so it is tracked under beads issue `mirofish-2ul1` rather than cherry-picked wholesale. +- The upstream sync tooling now mirrors missing PR refs to the fork during snapshot refresh instead of only annotating already-present refs. Focused regression coverage for that automation passed with `python3 -m unittest tests.test_sync_upstream_github`. + +- Upstream intake refreshed again on March 12, 2026 at `2026-03-12T00:44:15.147088+00:00` for the open queue and `2026-03-12T00:44:30.484008+00:00` for the full history snapshot. The machine-readable summaries remain current at `42` open issues / `39` open PRs and `92` total issues / `53` total PRs, with all `42` upstream issues mirrored into `ivanzud/MiroFish` and all `53` mirrored upstream PR heads still visible in `origin`. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PR queue is unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe or superseded branches (`#141`, `#144`, `#118`, `#114`, `#108`, `#102`, `#101`, `#100`, `#87`, `#86`, `#70`, `#38`, `#49`, `#72`). +- Direct OpenAI-compatible backend verification passed again in a clean shell with only `OPENAI_API_KEY`, `OPENAI_API_BASE_URL=https://codex.example.test/v1`, `OPENAI_MODEL=gpt-4.1-mini`, and `ZEP_API_KEY` set. `npm run check:backend-config -- --compact` still reports `llm.backend_mode = openai_compatible`, resolves `summary.llm.sources` to the expected direct `OPENAI_*` aliases, and emits only the expected generated-`SECRET_KEY` warning when `SECRET_KEY` is unset. +- Lightweight backend validation also passed again in this pass with `bash ./scripts/test_backend_lite.sh` (`166 passed`), so the refreshed upstream queue still does not expose a new reproducible low-risk code issue beyond the existing tracked design-sized gaps (`mirofish-975`, `mirofish-qoo`, `mirofish-as6`, `mirofish-8eg`, `mirofish-77h`, `mirofish-3j8`, `mirofish-hj9`, `mirofish-pfbl`). + +- Upstream intake refreshed again on March 12, 2026 at `2026-03-12T00:37:03.138774+00:00` for the open queue and immediately afterward for the full history snapshot. The machine-readable summaries remain current at `42` open issues / `39` open PRs and `92` total issues / `53` total PRs after issue `#154`, with all `42` upstream issues mirrored into `ivanzud/MiroFish` and `53` mirrored upstream PR heads currently visible in `origin`. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PR queue is unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe or superseded branches (`#141`, `#144`, `#118`, `#114`, `#108`, `#102`, `#101`, `#100`, `#87`, `#86`, `#70`, `#38`, `#49`, `#72`). +- Direct OpenAI-compatible backend verification passed again in a clean shell with only `OPENAI_API_KEY`, `OPENAI_API_BASE_URL=https://codex.example.test/v1`, `OPENAI_MODEL=gpt-4.1-mini`, and `ZEP_API_KEY` set. `npm run check:backend-config -- --compact` still reports `llm.backend_mode = openai_compatible`, resolves `summary.llm.sources` to the expected direct `OPENAI_*` aliases, and emits only the expected generated-`SECRET_KEY` warning when `SECRET_KEY` is unset. +- Lightweight backend validation also passed again in this pass with `bash ./scripts/test_backend_lite.sh` (`166 passed`), so the refreshed upstream queue still does not expose a new reproducible low-risk code issue beyond the existing tracked design-sized gaps (`mirofish-975`, `mirofish-qoo`, `mirofish-as6`, `mirofish-8eg`, `mirofish-77h`, `mirofish-3j8`, `mirofish-hj9`, `mirofish-pfbl`). + +- Upstream intake refreshed again on March 12, 2026 at `2026-03-12T00:27:45.379395+00:00` for the open queue and `2026-03-12T00:27:54.953492+00:00` for the full history snapshot. The machine-readable summaries now show `42` open issues / `39` open PRs and `92` total issues / `53` total PRs after upstream opened issue `#154`, with all open PR refs mirrored into `origin` and all upstream issue mirrors still visible in the fork artifacts. +- The new upstream issue `#154` was immediately reproducible from the traceback: `backend/app/services/oasis_profile_generator.py` assumed `bio` / `persona` / `country` were strings and crashed during save when structured JSON leaked through from LLM profile generation. This pass landed a repo-native fix that normalizes mixed dict/list profile fields on `OasisAgentProfile` construction and defensively re-coerces them in both Reddit JSON and Twitter CSV serialization. +- Safe-merge review after the refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PR queue is unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe or superseded branches (`#141`, `#144`, `#118`, `#114`, `#108`, `#102`, `#101`, `#100`, `#87`, `#86`, `#70`, `#38`, `#49`, `#72`). +- Validation for this pass passed with `cd backend && uv run pytest -q tests/test_openai_compat_services.py -k "structured_fields or save_profiles_defaults_country_by_locale or save_twitter_profiles_tolerates_structured_fields"` and `bash ./scripts/test_backend_lite.sh` (`166 passed`). Direct OpenAI-compatible backend support remains intact on this branch; no extra raw-provider-only setup work was needed beyond the already-documented `OPENAI_*` / `npm run backend:local` flow. + +- Upstream intake refreshed again on March 12, 2026 at `2026-03-12T00:23:38.604808+00:00` for the open queue and immediately afterward for the full history snapshot at `2026-03-12T00:23:48.651963+00:00`. The machine-readable summaries remain current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all open PR refs mirrored into `origin` and all upstream issue mirrors still visible in the fork artifacts. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PR queue is unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe or superseded branches (`#141`, `#144`, `#118`, `#114`, `#108`, `#102`, `#101`, `#100`, `#87`, `#86`, `#70`, `#38`, `#49`, `#72`). +- `README-RU.md` is now an actual Russian-language repo-native docs subset instead of an English placeholder, while still documenting the current branch's split install path (`setup:core` / `setup:all` versus optional `setup:backend:simulation`) and the direct `OPENAI_*` / Codex-compatible backend verification flow via `npm run check:backend-config` and `npm run backend:local`. +- Validation for this pass passed with `bash ./scripts/test_backend_lite.sh` (`164 passed`). The refreshed queue still does not expose a new reproducible low-risk code issue beyond the tracked design-sized follow-ups, so this cycle focused on keeping upstream intake current and tightening translated direct-backend docs instead of forcing an unsafe merge. + +- Upstream intake refreshed again on March 12, 2026 at `2026-03-12T00:20:37.715289+00:00` for the open queue and `2026-03-12T00:20:47.775909+00:00` for the full history snapshot. The machine-readable summaries still hold at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all open PR refs mirrored into `origin` and all upstream issue mirrors still current in the fork visibility artifacts. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PR queue is unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe or superseded branches (`#141`, `#144`, `#118`, `#114`, `#108`, `#102`, `#101`, `#100`, `#87`, `#86`, `#70`, `#38`, `#49`, `#72`). +- Revalidated the direct OpenAI-compatible backend path again in a clean shell with only `OPENAI_API_KEY`, `OPENAI_API_BASE_URL=https://codex.example.test/v1`, `OPENAI_MODEL=gpt-4.1-mini`, and `ZEP_API_KEY` set. `npm run check:backend-config -- --compact` still reports `llm.backend_mode = openai_compatible`, resolves the expected direct `OPENAI_*` alias sources, and emits only the expected generated-`SECRET_KEY` warning when `SECRET_KEY` is unset. +- Reconciled stale beads parent state after the refresh: `mirofish-975` and `mirofish-pfbl` moved back to `open` because their low-risk child work is exhausted for now, leaving only broader persisted deduplication and model-generated locale-enforcement design gaps instead of falsely advertising active execution-ready work. + +- Upstream intake refreshed again on March 12, 2026 at `2026-03-12T00:15:15.421005+00:00` for the open queue and `2026-03-12T00:15:25.122000+00:00` for the full history snapshot. The machine-readable summaries still hold at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all open PR refs mirrored into `origin` and all upstream issue mirrors still current in the fork visibility artifacts. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PR queue is unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe or superseded branches (`#141`, `#144`, `#118`, `#114`, `#108`, `#102`, `#101`, `#100`, `#87`, `#86`, `#70`, `#38`, `#49`, `#72`). +- Revalidated the direct OpenAI-compatible backend path again in a clean shell with only `OPENAI_API_KEY`, `OPENAI_API_BASE_URL=https://codex.example.test/v1`, `OPENAI_MODEL=gpt-4.1-mini`, and `ZEP_API_KEY` set. `npm run check:backend-config -- --compact` still reports `llm.backend_mode = openai_compatible`, resolves the expected `OPENAI_*` alias sources, and emits only the expected generated-`SECRET_KEY` warning when `SECRET_KEY` is unset. +- Full lightweight backend validation also passed again in this pass with `bash ./scripts/test_backend_lite.sh` (`164 passed`), so the refreshed upstream queue still does not expose a new reproducible low-risk issue beyond the existing tracked design-sized gaps (`mirofish-975`, `mirofish-qoo`, `mirofish-as6`, `mirofish-8eg`, `mirofish-77h`, `mirofish-3j8`, `mirofish-hj9`). + +- Upstream intake refreshed again on March 12, 2026 at `2026-03-12T00:11:49.607998+00:00` for the open queue and `2026-03-12T00:12:07.997854+00:00` for the full history snapshot. The machine-readable summaries still hold at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all open PR refs mirrored into `origin` and all upstream issue mirrors still current in the fork visibility artifacts. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PR queue is unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe or superseded branches (`#141`, `#144`, `#118`, `#114`, `#108`, `#102`, `#101`, `#100`, `#87`, `#86`, `#70`, `#38`, `#49`, `#72`). +- Revalidated the direct OpenAI-compatible backend path again in a clean shell with only `OPENAI_API_KEY`, `OPENAI_API_BASE_URL=https://codex.example.test/v1`, `OPENAI_MODEL=gpt-4.1-mini`, and `ZEP_API_KEY` set. `npm run check:backend-config -- --compact` still reports `llm.backend_mode = openai_compatible`, resolves the expected `OPENAI_*` alias sources, and emits only the expected generated-`SECRET_KEY` warning when `SECRET_KEY` is unset. +- Full lightweight backend validation also passed again in this pass with `bash ./scripts/test_backend_lite.sh` (`164 passed`), so the refreshed upstream queue still does not expose a new reproducible low-risk issue beyond the existing tracked design-sized gaps (`mirofish-975`, `mirofish-qoo`, `mirofish-as6`, `mirofish-8eg`, `mirofish-77h`, `mirofish-3j8`, `mirofish-hj9`). + +- Upstream intake refreshed again on March 12, 2026 at `2026-03-12T00:09:52.089991+00:00` for the open queue and `2026-03-12T00:10:01.652498+00:00` for the full history snapshot. The machine-readable summaries still hold at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all open PR refs mirrored into `origin` and all upstream issue mirrors still current in the fork visibility artifacts. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PR queue is unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe or superseded branches (`#141`, `#144`, `#118`, `#114`, `#108`, `#102`, `#101`, `#100`, `#87`, `#86`, `#70`, `#38`, `#49`, `#72`). +- Revalidated the direct OpenAI-compatible backend path again in a clean shell with only `OPENAI_API_KEY`, `OPENAI_API_BASE_URL=https://codex.example.test/v1`, `OPENAI_MODEL=gpt-4.1-mini`, and `ZEP_API_KEY` set. `npm run check:backend-config -- --compact` still reports `llm.backend_mode = openai_compatible`, resolves the expected `OPENAI_*` alias sources, and emits only the expected generated-`SECRET_KEY` warning when `SECRET_KEY` is unset. +- Full lightweight backend validation also passed again in this pass with `bash ./scripts/test_backend_lite.sh` (`164 passed`), so the refreshed upstream queue still does not expose a new reproducible low-risk issue beyond the existing tracked design-sized gaps (`mirofish-975`, `mirofish-qoo`, `mirofish-as6`, `mirofish-8eg`, `mirofish-77h`, `mirofish-3j8`, `mirofish-hj9`). + +- Upstream intake refreshed again on March 12, 2026 at `2026-03-12T00:05:31.453610+00:00` for the open queue and `2026-03-12T00:05:40.777595+00:00` for the full history snapshot. The machine-readable summaries remain current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all open PR refs still mirrored into `origin` and all upstream issue mirrors still current in the fork visibility artifacts. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PR queue is unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe or superseded branches (`#141`, `#144`, `#118`, `#114`, `#108`, `#102`, `#101`, `#100`, `#87`, `#86`, `#70`, `#38`, `#49`, `#72`). +- Revalidated the two currently relevant low-risk seams instead of forcing another stale merge: ontology/schema normalization still passes targeted regression coverage (`cd backend && uv run pytest -q tests/test_ontology_generator.py tests/test_graph_builder.py tests/test_llm_client.py`), and the direct OpenAI-compatible backend path still reports `llm.backend_mode = openai_compatible` with only `OPENAI_API_KEY`, `OPENAI_API_BASE_URL=https://codex.example.test/v1`, `OPENAI_MODEL=gpt-4.1-mini`, and `ZEP_API_KEY` set. +- Full lightweight backend validation also passed in this pass with `bash ./scripts/test_backend_lite.sh`, so there is no newly exposed reproducible low-risk upstream defect on the current branch after the latest forced refresh. + +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T23:59:10.108114+00:00` for the open queue and `2026-03-11T23:59:20.828413+00:00` for the full history snapshot. The machine-readable summaries remain current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all open PR refs mirrored into `origin` and all upstream issue mirrors still current in the fork visibility artifacts. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PR queue is unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe or superseded branches (`#141`, `#144`, `#118`, `#114`, `#108`, `#102`, `#101`, `#100`, `#87`, `#86`, `#70`, `#38`, `#49`, `#72`). +- Direct OpenAI-compatible backend docs parity improved again on this branch. `README-JA.md`, `README-KO.md`, and `README-RU.md` now document the preflighted `npm run backend:local` startup path alongside `npm run check:backend-config`, so the translated README set now surfaces the same backend-only verification flow already documented in the main Chinese and English guides. + +- Upstream intake remains current on March 11, 2026 from the latest `docs/upstream-open-state.json` / `docs/upstream-all-state.json` refresh at `2026-03-11T23:46:03.654119+00:00` and `2026-03-11T23:46:13.428826+00:00`, with `41` open issues / `39` open PRs, full-history coverage at `91` issues / `53` PRs, all open PR refs mirrored into `origin`, and all upstream issue mirrors current in the fork visibility artifacts. +- Another safe-merge review in this pass still did not expose a new clean upstream PR to adopt. The remaining non-landed open PR queue is unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe or superseded branches (`#141`, `#144`, `#118`, `#114`, `#108`, `#102`, `#101`, `#100`, `#87`, `#86`, `#70`, `#38`, `#49`, `#72`). +- Upstream issue `#145` now has a seventeenth repo-native partial mitigation on this branch. `frontend/src/components/graphPanelData.js` now exports a shared graph summary path that uses the same alias-collapse mapping as the visible graph renderer, and `frontend/src/components/Step1GraphBuild.vue`, `frontend/src/views/MainView.vue`, plus `frontend/src/views/Process.vue` now use that summary so displayed node/edge counters and refresh logs match the deduplicated graph the user actually sees instead of the raw duplicate-containing payload. Focused regression coverage lives in `frontend/tests/graphPanelData.test.mjs`. +- Validation for the latest frontend graph-stat dedup mitigation passed with `npm --prefix frontend test` and `npm --prefix frontend run build`. + +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T23:46:03.654119+00:00` for the open queue and `2026-03-11T23:46:13.428826+00:00` for the full history snapshot. The machine-readable summaries remain current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all open PR refs mirrored into `origin` and all upstream issue mirrors current in the fork visibility artifacts. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PR queue is unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe or superseded branches (`#141`, `#144`, `#118`, `#114`, `#108`, `#102`, `#101`, `#100`, `#87`, `#86`, `#70`, `#38`, `#49`, `#72`). +- Direct OpenAI-compatible backend support now has a more robust low-level fallback path on this branch. `backend/app/utils/llm_client.py` retries once without `response_format` when an OpenAI-compatible backend rejects JSON mode via either `BadRequestError` or `APIError`, which keeps direct Codex/OpenAI-compatible backends working in more runtime paths than the generator-only fallback coverage alone. +- The direct backend startup path is now easier to verify locally: `package.json` adds `npm run backend:local`, and `README.md` / `README-EN.md` document that it runs the same config-status preflight before starting Flask so direct `OPENAI_*` / Codex-compatible setups fail early on config errors instead of only after launch. +- Validation for this pass passed with `cd backend && uv run pytest -q tests/test_llm_client.py`, `bash ./scripts/test_backend_lite.sh`, and a clean-shell `npm run check:backend-config -- --compact` invocation with only `OPENAI_API_KEY`, `OPENAI_API_BASE_URL=https://codex.example.test/v1`, `OPENAI_MODEL=gpt-4.1-mini`, and `ZEP_API_KEY` set. + +- Upstream intake remains current on March 11, 2026 from the latest `docs/upstream-open-state.json` / `docs/upstream-all-state.json` refresh at `2026-03-11T23:35:03.722605+00:00` and `2026-03-11T23:35:13.535197+00:00`, with `41` open issues / `39` open PRs, full-history coverage at `91` issues / `53` PRs, all upstream PR refs mirrored into `origin`, and all upstream issue mirrors current in the fork visibility artifacts. +- Another safe-merge review in this pass still did not expose a new clean upstream PR to adopt. The remaining non-landed open PR queue is unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe or superseded branches (`#141`, `#144`, `#118`, `#114`, `#108`, `#102`, `#101`, `#100`, `#87`, `#86`, `#70`, `#38`, `#49`, `#72`). +- The translated quick-start docs now match the current core install path. `README-JA.md` and `README-KO.md` now recommend `npm run setup:core`, keep `npm run setup:all` explicitly documented as the backward-compatible alias, and continue to mark `npm run setup:backend:simulation` as the optional Step 3 / Step 5 runtime install so the non-English setup guides stay aligned with the branch's direct OpenAI-compatible backend path. +- Direct OpenAI-compatible backend verification passed again on March 11, 2026 via a clean-shell `npm run check:backend-config -- --compact` invocation with only `OPENAI_API_KEY`, `OPENAI_API_BASE_URL=https://codex.example.test/v1`, `OPENAI_MODEL=gpt-4.1-mini`, and `ZEP_API_KEY` set. The backend still reports `llm.backend_mode = openai_compatible`, resolves `summary.llm.sources` to the expected direct `OPENAI_*` aliases, and emits only the expected generated-`SECRET_KEY` warning when `SECRET_KEY` is unset. +- Validation for this pass passed with `bash ./scripts/validate_repo.sh --skip-frontend-build`. + +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T23:28:12.176804+00:00` for the open queue and `2026-03-11T23:28:22.417298+00:00` for the full history snapshot. The machine-readable summaries remain current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all open PR refs mirrored into `origin` and all upstream issue mirrors still current in the fork visibility artifacts. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PR queue is unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe or superseded branches (`#141`, `#144`, `#118`, `#114`, `#108`, `#102`, `#101`, `#100`, `#87`, `#86`, `#70`, `#38`, `#49`, `#72`). +- Upstream issue `#145` now has another repo-native partial mitigation on this branch. The graph detail drawers in both `frontend/src/views/Process.vue` and `frontend/src/components/GraphPanel.vue` now expose the merged non-canonical `alias_names` through the shared `frontend/src/components/graphAliasDetails.js` helper, so users can see which folded labels were collapsed into the displayed canonical node instead of only seeing the surviving name. Focused regression coverage lives in `frontend/tests/graphAliasDetails.test.mjs`. +- Validation for this pass passed with `npm --prefix frontend test` and `npm --prefix frontend run build`. + +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T23:16:14.715421+00:00` for the open queue and `2026-03-11T23:16:24.323676+00:00` for the full history snapshot. The machine-readable summaries remain current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all open PR refs mirrored into `origin` and all upstream issue mirrors still current in the fork visibility artifacts. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PR queue is unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe or superseded branches (`#141`, `#144`, `#118`, `#114`, `#108`, `#102`, `#101`, `#100`, `#87`, `#86`, `#70`, `#38`, `#49`, `#72`). +- Locale-enforcement follow-up bead `mirofish-pfbl` now has another repo-native partial mitigation on this branch. `backend/app/services/oasis_profile_generator.py` and `backend/app/services/simulation_config_generator.py` now route their deterministic `response_format=json_object` fallback warning through backend i18n, so English and Chinese runs no longer share the same hardcoded English retry warning when OpenAI-compatible backends reject JSON mode. +- Validation for this pass passed with `cd backend && uv run pytest -q tests/test_openai_compat_services.py` and `python3 -m compileall backend/app/i18n.py backend/app/services/oasis_profile_generator.py backend/app/services/simulation_config_generator.py backend/tests/test_openai_compat_services.py`. +- Upstream PR `#119` now has another repo-native partial localization mitigation on this branch. `backend/app/services/zep_tools.py` now tells the English-mode interview-summary prompt to use standard English quotation marks for direct quotes, which closes another small mixed-language formatting seam when report/interview material contains Chinese source text. Focused regression coverage lives in `backend/tests/test_zep_tools_i18n.py`. +- Validation for this pass passed with `uv run --project backend pytest -q backend/tests/test_zep_tools_i18n.py` and `python3 -m compileall backend/app/services/zep_tools.py backend/tests/test_zep_tools_i18n.py`. +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T23:11:05.875776+00:00` for the open queue and `2026-03-11T23:11:15.815928+00:00` for the full history snapshot. The machine-readable summaries remain current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all open PR refs mirrored into `origin` and all upstream issue mirrors still current in the fork visibility artifacts. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PR queue is unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe or superseded branches (`#141`, `#144`, `#118`, `#114`, `#108`, `#102`, `#101`, `#100`, `#87`, `#86`, `#70`, `#38`, `#49`, `#72`). +- Added an explicit `npm run setup:core` alias for the core root/frontend/backend install path and updated the README set to recommend that path for direct OpenAI/Codex-compatible graph/report usage, while keeping `npm run setup:all` as a backward-compatible alias and `npm run setup:backend:simulation` as the clearly optional Step 3 / Step 5 runtime install. +- Direct OpenAI-compatible backend verification passed again on March 11, 2026 via `env -i ... npm run check:backend-config -- --compact` with only `OPENAI_API_KEY`, `OPENAI_API_BASE_URL=https://codex.example.test/v1`, `OPENAI_MODEL=gpt-4.1-mini`, and `ZEP_API_KEY` set. The backend still reports `llm.backend_mode = openai_compatible`, resolves `summary.llm.sources` to the expected direct `OPENAI_*` aliases, and emits only the expected generated-`SECRET_KEY` warning when `SECRET_KEY` is unset. +- Upstream issue `#62` now has another repo-native timeout mitigation on this branch. `backend/app/services/zep_tools.py` no longer hardcodes a 180-second timeout when `interview_agents()` calls the live batch interview path for report-agent workflows; it now honors `INTERVIEW_BATCH_TIMEOUT_SECONDS`, so slower local/OpenAI-compatible backends can extend that path through the same backend timeout knob already documented for Step 5 UI interviews. Focused regression coverage lives in `backend/tests/test_zep_tools_i18n.py`. +- Validation for this pass passed with `cd backend && uv run pytest -q tests/test_zep_tools_i18n.py` and `python3 -m compileall backend/app/services/zep_tools.py backend/tests/test_zep_tools_i18n.py`. +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T23:04:08.533465+00:00` for the open queue and `2026-03-11T23:04:18.143487+00:00` for the full history snapshot. The machine-readable summaries remain current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all open PR refs and mirrored upstream issue summaries still current in the fork visibility artifacts. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PRs are unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe or superseded branches (`#141`, `#144`, `#118`, `#114`, `#108`, `#102`, `#101`, `#100`, `#87`, `#86`, `#70`, `#38`, `#49`, `#72`). +- Re-ran the current English-mode/model-locale regression bundle while checking for another low-risk partial follow-up from upstream PR `#119`; the focused suites still pass (`cd backend && uv run pytest -q tests/test_openai_compat_services.py tests/test_report_agent.py tests/test_zep_tools_i18n.py tests/test_ontology_generator.py`), but they did not expose a new deterministic locale seam worth landing safely in this pass. + +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T23:00:40.914406+00:00` for the open queue and `2026-03-11T23:00:50.561481+00:00` for the full history snapshot. The machine-readable summaries remain current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all open PR refs and mirrored upstream issue summaries still current in the fork visibility artifacts. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PRs are unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe or superseded branches (`#141`, `#144`, `#118`, `#114`, `#108`, `#102`, `#101`, `#100`, `#87`, `#86`, `#70`, `#38`, `#49`, `#72`). +- Upstream issue `#117` and the remaining locale-enforcement follow-up bead `mirofish-pfbl` now have another repo-native partial mitigation on this branch. `backend/app/services/zep_tools.py` now tells the English-mode sub-question planner, interview-agent selector, interview-question generator, and interview-summary prompt builder to keep natural-language output in English and to translate mixed-language interview material before summarizing it, which reduces another prompt-level path for English UI/report workflows to drift back into Chinese even when the simulation content is mixed-language. +- Validation for this pass passed with `cd backend && uv run pytest -q tests/test_zep_tools_i18n.py` and `python3 -m compileall backend/app/services/zep_tools.py backend/tests/test_zep_tools_i18n.py`. + +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T22:52:13.591715+00:00` for the open queue and `2026-03-11T22:52:23.016686+00:00` for the full history snapshot. The machine-readable summaries remain current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all open PR refs and mirrored upstream issue summaries still current in the fork visibility artifacts. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PRs are unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe or superseded branches (`#141`, `#144`, `#118`, `#114`, `#108`, `#102`, `#101`, `#100`, `#87`, `#86`, `#70`, `#38`, `#49`, `#72`). +- Direct OpenAI-compatible backend verification passed again on March 11, 2026 in both script and npm entrypoints: `env -i ... uv run --project backend python backend/scripts/print_config_status.py --locale en --compact` and `env -i ... npm run check:backend-config -- --compact` both succeeded with only `OPENAI_API_KEY`, `OPENAI_API_BASE_URL=https://codex.example.test/v1`, `OPENAI_MODEL=gpt-4.1-mini`, and `ZEP_API_KEY` set. The backend still reports `llm.backend_mode = openai_compatible`, resolves `summary.llm.sources` to the expected direct `OPENAI_*` aliases, and emits only the expected generated-`SECRET_KEY` warning when `SECRET_KEY` is unset. +- Reconciled the remaining deterministic English-localization backlog: beads issue `mirofish-1nh` is now closed after auditing the surviving seams, and the only remaining locale drift is tracked separately under `mirofish-pfbl` as model-generated content enforcement work rather than another low-risk hardcoded-translation pass. +- Validation for this pass passed with `cd backend && uv run pytest -q tests/test_print_config_status.py tests/test_llm_env.py` and the clean-shell `npm run check:backend-config -- --compact` smoke check above. + +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T22:42:42.336682+00:00` for the open queue and `2026-03-11T22:42:52.136744+00:00` for the full history snapshot. The machine-readable summaries remain current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all open PR refs and mirrored upstream issue summaries still current in the fork visibility artifacts. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PRs are unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe or superseded branches (`#141`, `#144`, `#118`, `#114`, `#108`, `#102`, `#101`, `#100`, `#87`, `#86`, `#70`, `#38`, `#49`, `#72`). +- Direct OpenAI-compatible backend verification passed again on March 11, 2026 via `env -i ... uv run --project backend python backend/scripts/print_config_status.py --locale en --compact` with only `OPENAI_API_KEY`, `OPENAI_API_BASE_URL=https://codex.example.test/v1`, `OPENAI_MODEL=gpt-4.1-mini`, and `ZEP_API_KEY` set. The backend still reports `llm.backend_mode = openai_compatible`, resolves `summary.llm.sources` to the expected direct `OPENAI_*` aliases, and emits only the expected generated-`SECRET_KEY` warning when `SECRET_KEY` is unset. +- Upstream PR `#119` now has another repo-native partial localization mitigation on this branch. `backend/app/services/report_agent.py` now builds the section-generation system/user prompt scaffolding from locale-aware templates, so locale=en Step 4 drafting no longer starts from Chinese instructions, examples, or tool-usage guidance before entering the localized ReACT loop. Focused regression coverage now lives in `backend/tests/test_report_agent.py`. +- Validation for this pass passed with `cd backend && uv run pytest -q tests/test_report_agent.py`, `python3 -m compileall backend/app/services/report_agent.py backend/tests/test_report_agent.py`, and `bash ./scripts/test_backend_lite.sh`. +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T22:29:39.529697+00:00` for the open queue and `2026-03-11T22:29:49.648789+00:00` for the full history snapshot. The machine-readable summaries remain current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all open PR refs and open upstream issue mirrors still current in the fork visibility artifacts. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PRs are unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe branches (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`). +- Upstream PR `#119` now has another repo-native partial localization mitigation on this branch. `backend/app/services/report_agent.py` now localizes the embedded tool catalog descriptions and parameter help before English-mode report prompts are assembled, so locale=en report generation no longer injects Chinese-only `insight_forge` / `panorama_search` / `quick_search` / `interview_agents` usage text into the ReACT/tool prompt surface. Focused regression coverage now lives in `backend/tests/test_report_agent.py`. +- Validation for this pass passed with `cd backend && uv run pytest -q tests/test_report_agent.py`, `python3 -m compileall backend/app/services/report_agent.py backend/tests/test_report_agent.py`, and `bash ./scripts/test_backend_lite.sh`. +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T22:24:28.341403+00:00` for the open queue and `2026-03-11T22:24:37.950290+00:00` for the full history snapshot. The machine-readable summaries remain current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all `39` open PR refs and all `41` open upstream issue mirrors still current in the fork visibility artifacts. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. Every open PR remains mirrored into `origin`, every open issue remains mirrored into the fork issue summaries, and the remaining non-landed open PRs are unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe branches (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`). +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T22:22:26.292559+00:00` for the open queue and `2026-03-11T22:22:36.069047+00:00` for the full history snapshot. The machine-readable summaries remain current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with reviewed mirror metadata preserved in the local summaries. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PRs are unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe branches (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`). +- Upstream PR `#119` now has another repo-native partial localization mitigation on this branch. `backend/app/services/simulation_config_generator.py` now explicitly requires English free-text output in the English time/event prompt builders, so locale=en config generation is less likely to drift back into Chinese for reasoning, narrative direction, and initial post content. Focused regression coverage now lives in `backend/tests/test_openai_compat_services.py`. +- Validation for this pass passed with `cd backend && uv run pytest -q tests/test_openai_compat_services.py` and `python3 -m compileall backend/app/services/simulation_config_generator.py backend/tests/test_openai_compat_services.py`. +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T21:57:13.711355+00:00` for the open queue and `2026-03-11T21:57:31.816896+00:00` for the full history snapshot. The machine-readable summaries remain current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all open issues and reviewed PR refs still mirrored into the fork visibility artifacts. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PRs are unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe branches (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`). +- Upstream PR `#119` now has another repo-native partial localization mitigation on this branch. `backend/app/services/simulation_config_generator.py` routes deterministic unknown entity/poster fallback labels through the active locale, so zh-mode simulation-config summaries, generated agent-config metadata, and initial-post defaults no longer leak the hardcoded English placeholder `Unknown`. Focused regression coverage now lives in `backend/tests/test_openai_compat_services.py`. +- Validation for this pass passed with `cd backend && uv run pytest -q tests/test_openai_compat_services.py` and `python3 -m compileall backend/app/services/simulation_config_generator.py backend/tests/test_openai_compat_services.py`. +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T21:57:13.711355+00:00` for the open queue and `2026-03-11T21:57:31.816896+00:00` for the full history snapshot. The machine-readable summaries remain current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all open issues and reviewed PR refs still mirrored into the fork visibility artifacts. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PRs are unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe branches (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`). +- Direct OpenAI-compatible backend verification remains covered on this branch without any repo-specific provider toggle: `npm run check:backend-config` still accepts direct `OPENAI_*` aliases and reports `llm.backend_mode = openai_compatible`, so no extra implementation work was needed for Codex/OpenAI-compatible backend support in this intake pass. +- Direct OpenAI-compatible backend verification passed again on March 11, 2026 with only `OPENAI_API_KEY`, `OPENAI_API_BASE_URL=https://codex.example.test/v1`, `OPENAI_MODEL=gpt-4.1-mini`, and `ZEP_API_KEY` set in a clean shell. `npm run check:backend-config` still reports `llm.backend_mode = openai_compatible`, resolves `summary.llm.sources` to the expected direct `OPENAI_*` aliases, and the only warning is the expected temporary `SECRET_KEY` notice for local verification when `SECRET_KEY` is unset. +- Documentation now calls out that local-only generated-`SECRET_KEY` warning explicitly in every README verification section so users do not misread a successful direct Codex/OpenAI-compatible check as a configuration failure. +- Upstream PR `#119` now has another repo-native partial localization mitigation on this branch. `backend/app/services/graph_builder.py` now emits the initial worker task-progress messages in the active locale, so locale=en persists English build-start / graph-created / ontology-set / chunk-split / wait-for-Zep / graph-info status strings before the API translation shim runs. Focused regression coverage now lives in `backend/tests/test_graph_builder.py`. +- Validation for this pass passed with `uv run --project backend pytest -q backend/tests/test_graph_builder.py backend/tests/test_graph_upload_api.py` and `python3 -m compileall backend/app/services/graph_builder.py backend/tests/test_graph_builder.py`. +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T21:40:48.205758+00:00` for the open queue and `2026-03-11T21:41:08.697409+00:00` for the full history snapshot. The machine-readable summaries remain current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all open issues and reviewed PR refs still mirrored into the fork visibility artifacts. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PRs are unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe branches (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`). +- Upstream PR `#119` now has another repo-native partial localization mitigation on this branch. `backend/app/services/zep_tools.py` now extracts and renders Step 5 interview `key_quotes` with English punctuation and quote-pair handling instead of assuming Chinese-only sentence boundaries, so locale=en interview summaries no longer silently lose strong quotes from otherwise valid answers. Focused regression coverage now lives in `backend/tests/test_zep_tools_i18n.py`. +- Direct OpenAI-compatible backend verification still passes on this branch without any project-specific `LLM_*` variables: `npm run check:backend-config` was re-run under a clean environment with only `OPENAI_API_KEY`, `OPENAI_API_BASE_URL=https://codex.example.test/v1`, `OPENAI_MODEL=gpt-4.1-mini`, and `ZEP_API_KEY`, and the emitted config-status payload still reports `llm.backend_mode = openai_compatible` with `summary.llm.sources` resolving to the expected direct `OPENAI_*` aliases. +- Validation for this pass passed with `cd backend && uv run pytest -q tests/test_zep_tools_i18n.py`, `python3 -m compileall backend/app/services/zep_tools.py backend/tests/test_zep_tools_i18n.py`, and `env -i ... npm run check:backend-config`. +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T21:27:47.644900+00:00` for the open queue and `2026-03-11T21:27:57.668178+00:00` for the full history snapshot. The machine-readable summaries remain current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all open issues and reviewed PR refs still mirrored into the fork visibility artifacts. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PRs are unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe branches (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`). +- Upstream issue `#145` now has an eleventh repo-native partial mitigation on this branch. `backend/app/services/zep_tools.py` now collapses obvious alias duplicates in `get_all_nodes()` and remaps duplicate alias-linked edges in `get_all_edges()`, so raw graph introspection payloads no longer expose both `特朗普` and `美国总统特朗普` or duplicate edge rows before downstream report-side tooling consumes the snapshot. Focused regression coverage now lives in `backend/tests/test_zep_tools_dedup.py`. +- Direct OpenAI-compatible backend verification still passes on this branch without any project-specific `LLM_*` variables: `npm run check:backend-config` was re-run under a clean environment with only `OPENAI_API_KEY`, `OPENAI_API_BASE_URL=https://codex.example.test/v1`, `OPENAI_MODEL=gpt-4.1-mini`, and `ZEP_API_KEY`, and the emitted config-status payload still reports `llm.backend_mode = openai_compatible` with `summary.llm.sources` resolving to the expected direct `OPENAI_*` aliases. +- Validation for this pass passed with `env -i ... npm run check:backend-config` and `cd backend && uv run pytest -q tests/test_print_config_status.py`. +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T21:19:19.286925+00:00` for the open queue and `2026-03-11T21:19:29.371592+00:00` for the full history snapshot. The machine-readable summaries remain current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all open issues and reviewed PR refs still mirrored into the fork visibility artifacts. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PRs are unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe branches (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`). +- Upstream PR `#119` now has another repo-native partial localization mitigation on this branch. `backend/app/services/zep_tools.py` uses dedicated English InsightForge sub-query planning prompts and English fallback sub-query templates for `locale=en`, so the report-side analysis path no longer seeds LLM planning with Chinese-only instructions when the UI requests English. Focused regression coverage now lives in `backend/tests/test_zep_tools_i18n.py`. +- Validation for this pass passed with `cd backend && uv run pytest -q tests/test_zep_tools_i18n.py`, `python3 -m compileall backend/app/services/zep_tools.py backend/tests/test_zep_tools_i18n.py`, and `bash ./scripts/test_backend_lite.sh`. +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T21:13:28Z` for the open queue; mirror coverage remains current in the fork visibility artifacts, and another safe-merge review still found no new clean upstream PR to adopt beyond the already-landed or intentionally-tracked set. +- Upstream issue `#145` now has a tenth repo-native partial mitigation on this branch. `backend/app/services/zep_tools.py` resolves `get_node_edges()` against the same conservative alias group already used elsewhere, so node-edge lookups no longer drop relationships attached only to an alias UUID such as `美国总统特朗普` after the canonical node collapses to `特朗普`. Focused regression coverage now lives in `backend/tests/test_zep_tools_dedup.py`. +- Validation for the latest node-edge alias-dedup mitigation passed with `cd backend && uv run pytest -q tests/test_zep_tools_dedup.py`, `python3 -m compileall backend/app/services/zep_tools.py backend/tests/test_zep_tools_dedup.py`, and `bash ./scripts/test_backend_lite.sh`. +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T21:02:32.272514+00:00` for the open queue and `2026-03-11T21:02:53.066380+00:00` for the full history snapshot. The machine-readable summaries remain current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all open issues and reviewed PR refs still mirrored into the fork visibility artifacts. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PRs are unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe branches (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`). +- English-mode report outline planning is a little less Chinese-first now: `backend/app/services/report_agent.py` uses dedicated English planning-prompt scaffolding for `locale=en` instead of mixing an English output suffix into otherwise Chinese-first prompt templates, so title/summary/section planning no longer starts from Chinese-only instructions in the direct English path. Focused regression coverage now lives in `backend/tests/test_report_agent.py`. +- Validation for this pass passed with `uv run --project backend pytest -q backend/tests/test_report_agent.py` and `python3 -m compileall backend/app/services/report_agent.py backend/tests/test_report_agent.py`. +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T21:00:09.409604+00:00` for the open queue and `2026-03-11T21:00:19.619533+00:00` for the full history snapshot. The machine-readable summaries remain current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all open issues and reviewed PR refs still mirrored into the fork visibility artifacts. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PRs are unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe branches (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`). +- README parity is a little tighter now: `README-KO.md` and `README-JA.md` now document the same optional `npm run setup:backend:simulation` split, Python 3.13+/Rust caveat, and browser-refresh/runtime-session behavior that were already called out in the Chinese and English guides, which keeps the direct OpenAI-compatible/core-backend setup path clearer across localized docs. +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T20:57:41.601519+00:00` for the open queue and `2026-03-11T20:57:51.452185+00:00` for the full history snapshot. The machine-readable summaries remain current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all open issues and reviewed PR refs still mirrored into the fork visibility artifacts. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PRs are unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe branches (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`). +- English-mode report-agent runtime diagnostics are a little less Chinese-first now: `backend/app/services/report_agent.py` routes the section-generation LLM preview debug line through the existing locale-aware logger, so `locale=en` no longer emits the raw `LLM响应` preview while a report section is iterating. Focused regression coverage now lives in `backend/tests/test_report_agent.py`. +- Validation for this pass passed with `cd backend && uv run pytest -q tests/test_report_agent.py`, `python3 -m compileall backend/app/services/report_agent.py backend/tests/test_report_agent.py`, and `bash ./scripts/test_backend_lite.sh`. +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T20:52:36.033346+00:00` for the open queue and `2026-03-11T20:52:45.825231+00:00` for the full history snapshot. The machine-readable summaries remain current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all open issues and reviewed PR refs still mirrored into the fork visibility artifacts. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PRs are unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe branches (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`). +- English-mode graph-memory updater config errors are a little less mixed-language now: `backend/app/services/zep_graph_memory_updater.py` now resolves its startup locale from the explicit constructor argument or request locale before raising the deterministic missing-`ZEP_API_KEY` error, so `locale=en` callers no longer get a Chinese config exception before the updater starts. Focused regression coverage now lives in `backend/tests/test_openai_compat_services.py`. +- Validation for this pass passed with `cd backend && uv run pytest -q tests/test_openai_compat_services.py`, `python3 -m compileall backend/app/services/zep_graph_memory_updater.py backend/tests/test_openai_compat_services.py`, and `bash ./scripts/test_backend_lite.sh`. +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T20:40:38.988559+00:00` for the open queue and `2026-03-11T20:40:48.784550+00:00` for the full history snapshot. The machine-readable summaries remain current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all open issues and reviewed PR refs still mirrored into the fork visibility artifacts. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PRs are unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe branches (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`). +- Direct OpenAI-compatible backend diagnostics are a little safer now: `backend/app/config.py` detects conflicting `LLM_BASE_URL` / `OPENAI_BASE_URL` / `OPENAI_API_BASE_URL` values, `backend/scripts/print_config_status.py` and `/api/graph/config/status` now expose which alias won, and the frontend backend-diagnostics panel surfaces that conflict instead of silently showing only the selected value. README docs now also spell out the precedence order for Codex/OpenAI-compatible base URLs. +- Validation for this pass passed with `cd backend && uv run pytest -q tests/test_config.py tests/test_print_config_status.py tests/test_graph_upload_api.py`, `cd frontend && npm test`, and `python3 -m compileall backend/app/config.py backend/app/i18n.py backend/scripts/print_config_status.py`. +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T20:31:06.846052+00:00` for the open queue and `2026-03-11T20:31:17.346563+00:00` for the full history snapshot. The machine-readable summaries remain current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all open issues and reviewed PR refs still mirrored into the fork visibility artifacts. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PRs are unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe branches (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`). +- English-mode backend diagnostics are a little less mixed-language now: `backend/app/utils/zep_paging.py` routes deterministic Zep page-retry, page-failure, node-limit, and missing-UUID pagination diagnostics through shared backend i18n, and the locale-aware callers in `backend/app/services/graph_builder.py`, `backend/app/services/zep_entity_reader.py`, and `backend/app/services/zep_tools.py` now pass the active locale into that helper while preserving compatibility with older test doubles. +- Validation for this pass passed with `cd backend && uv run pytest -q tests/test_zep_paging_i18n.py tests/test_zep_entity_reader.py tests/test_graph_builder.py`, `python3 -m compileall backend/app/utils/zep_paging.py backend/app/i18n.py backend/app/services/zep_entity_reader.py backend/app/services/zep_tools.py backend/app/services/graph_builder.py backend/tests/test_zep_paging_i18n.py`, and `bash ./scripts/test_backend_lite.sh`. +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T20:20:47.560407+00:00` for the open queue and `2026-03-11T20:21:08.125218+00:00` for the full history snapshot. The machine-readable summaries remain current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all open issues and reviewed PR refs still mirrored into the fork visibility artifacts. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PRs are unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe branches (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`). +- English-mode backend diagnostics are a little less Chinese-first now: `backend/app/api/simulation.py` localizes deterministic task-status, report-lookup, and realtime profile/config read-failure logs, `backend/app/services/simulation_config_generator.py` now routes truncated-output repair warnings through shared backend i18n, and `backend/app/services/zep_entity_reader.py` localizes `get_entity_with_context()` failure logs for locale=`en`. +- Validation for this pass passed with `cd backend && uv run pytest -q tests/test_simulation_api_i18n.py tests/test_zep_entity_reader.py tests/test_openai_compat_services.py`, `python3 -m compileall backend/app/api/simulation.py backend/app/services/simulation_config_generator.py backend/app/services/zep_entity_reader.py backend/app/i18n.py backend/tests/test_simulation_api_i18n.py backend/tests/test_zep_entity_reader.py backend/tests/test_openai_compat_services.py`, and `bash ./scripts/test_backend_lite.sh`. +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T20:16:51.302281+00:00` for the open queue and `2026-03-11T20:16:58.616557+00:00` for the full history snapshot. The machine-readable summaries remain current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all open issues and reviewed PR refs still mirrored into the fork visibility artifacts. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PRs are unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe branches (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`). +- English-mode graph API diagnostics are a little less Chinese-first now: `backend/app/api/graph.py` routes deterministic ontology-generation and graph-build lifecycle logs through backend i18n, so the start banner, document-parse warning, project/task creation logs, text-extraction summary, LLM ontology-call log, and graph-build worker start/completion/failure logs no longer emit Chinese-only strings when locale=`en`. +- Validation for this localization pass passed with `cd backend && uv run pytest -q tests/test_graph_upload_api.py`, `python3 -m compileall backend/app/api/graph.py backend/app/i18n.py backend/tests/test_graph_upload_api.py`, and `bash ./scripts/test_backend_lite.sh`. +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T20:09:54.860259+00:00` for the open queue and `2026-03-11T20:10:02.469691+00:00` for the full history snapshot. The machine-readable summaries remain current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all open issues and reviewed PR refs still mirrored into the fork visibility artifacts. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PRs are unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe branches (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`). +- English-mode simulation API diagnostics are a little less Chinese-first now: `backend/app/api/simulation.py` routes deterministic graph-entity fetch and `/prepare` request/preflight log lines through locale-aware helpers, so those request/preparation logs no longer emit Chinese-only lifecycle messages when locale=`en`. +- Validation for this localization pass passed with `cd backend && uv run pytest -q tests/test_simulation_api_i18n.py` and `bash ./scripts/test_backend_lite.sh`. +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T19:58:06.961190+00:00` for the open queue and `2026-03-11T19:58:17.104753+00:00` for the full history snapshot. The machine-readable summaries remain current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all open issues and reviewed PR refs still mirrored into the fork visibility artifacts. +- Upstream issue `#145` now has a ninth repo-native partial mitigation on this branch. `backend/app/services/zep_entity_reader.py` now resolves `get_entity_with_context()` against the requested entity's obvious alias group, includes alias-linked relations from the full graph edge set, and deduplicates related-node payloads so the entity-detail API no longer drops context attached only to an alias UUID such as `美国总统特朗普` when the requested node is `特朗普`. +- Upstream PR `#152` is now covered by another repo-native subset on top of the earlier ontology naming hardening: `backend/app/models/task.py` now persists TaskManager state under the uploads tree with atomic writes and reloads it on startup, so graph/report task status survives backend restarts instead of disappearing with the in-memory singleton. That addresses the otherwise-useful task-persistence portion of the upstream branch without taking its unrelated mixed frontend/backend changes. +- Validation for the latest `#152` follow-up passed with `uv run --project backend pytest -q backend/tests/test_task_manager.py backend/tests/test_backend_localized_errors.py` and `python3 -m compileall backend/app/models/task.py backend/tests/test_task_manager.py`. +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T19:48:26.766499+00:00` for the open queue and `2026-03-11T19:48:36.673682+00:00` for the full history snapshot. The machine-readable summaries remain current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all open issues and reviewed PR refs still mirrored into the fork visibility artifacts. +- Another safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. Every open PR is now classified in the local coverage map as landed, superseded, partial, tracked, or not safe, and the remaining non-landed queue is unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe branches (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`). +- English-mode graph-memory updater diagnostics are less Chinese-first now: `backend/app/services/zep_graph_memory_updater.py` routes deterministic startup, queue, batch-send, retry, flush, and manager lifecycle logs through a locale helper, so simulation graph-memory control-plane logs no longer emit Chinese-only status lines when locale=`en`. +- Validation for this pass passed with `cd backend && uv run pytest -q tests/test_openai_compat_services.py`, `python3 -m compileall backend/app/services/zep_graph_memory_updater.py backend/tests/test_openai_compat_services.py`, and `bash ./scripts/test_backend_lite.sh`. +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T19:42:00.462462+00:00` for the open queue and `2026-03-11T19:42:10.671412+00:00` for the full history snapshot. The machine-readable summaries remain current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all `39` open PR refs, all `53` reviewed PR refs, all `41` open issues, and all `91` total issues mirrored into the fork visibility artifacts. +- A fresh safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PRs are unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe branches (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`). +- English-mode graph-entity reader diagnostics are a little less Chinese-first now: `backend/app/services/zep_entity_reader.py` routes deterministic retry/fetch/filter log lines through backend i18n, so node/edge fetch counts, retry warnings, and entity-filter summaries no longer leak Chinese-only text when locale=`en`. +- Validation for this pass passed with `cd backend && uv run pytest -q tests/test_zep_entity_reader.py tests/test_openai_compat_services.py`, `python3 -m compileall backend/app/services/zep_entity_reader.py backend/app/i18n.py backend/tests/test_zep_entity_reader.py`, and `bash ./scripts/test_backend_lite.sh`. +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T19:31:26.644907+00:00` for the open queue and `2026-03-11T19:31:36.711036+00:00` for the full history snapshot. The machine-readable summaries remain current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all `39` open PR refs, all `53` reviewed PR refs, all `41` open issues, and all `91` total issues mirrored into the fork visibility artifacts. +- A fresh safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PRs are unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe branches (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`). +- English-mode Step 4/report console diagnostics are less Chinese-first now: `backend/app/services/zep_tools.py` routes deterministic graph/node/edge fetch, graph-statistics, QuickSearch, PanoramaSearch, and InsightForge control-plane log lines through the locale helper, so `console_log.txt` and related debugging/report surfaces no longer leak Chinese-only search diagnostics when locale=`en`. +- Validation for this localization pass passed with `cd backend && uv run pytest -q tests/test_zep_tools_i18n.py`, `python3 -m compileall backend/app/services/zep_tools.py backend/tests/test_zep_tools_i18n.py`, and `bash ./scripts/test_backend_lite.sh`. +- Upstream intake refreshed again on March 11, 2026 at the latest snapshot timestamps recorded in `docs/upstream-open-state.json` and `docs/upstream-all-state.json`. The machine-readable summaries remain current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all open PR refs and mirrored issue summaries still current in the fork visibility artifacts. +- A fresh safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PRs are unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe branches (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`). +- English-mode backend diagnostics are a little less Chinese-first now: `backend/app/api/simulation.py` routes deterministic prepare-check, auto-ready reconciliation, force-restart cleanup, and graph-memory-enable logs through backend i18n, so those control-plane messages no longer leak Chinese-only text when locale=`en`. +- Validation for this localization pass passed with `cd backend && uv run pytest -q tests/test_simulation_api_i18n.py`, `python3 -m compileall backend/app/api/simulation.py backend/app/i18n.py`, and `bash ./scripts/test_backend_lite.sh`. +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T19:13:21.918532+00:00` for the open queue and `2026-03-11T19:13:32.122801+00:00` for the full history snapshot. The machine-readable summaries remain current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all `39` open PR refs, all `53` reviewed PR refs, all `41` open issues, and all `91` total issues mirrored into the fork visibility artifacts. +- A fresh safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PRs are unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe branches (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`). +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T19:21:51.452675+00:00` for the open queue and `2026-03-11T19:22:02.741743+00:00` for the full history snapshot. The machine-readable summaries remain current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all `39` open PR refs, all `53` reviewed PR refs, all `41` open issues, and all `91` total issues mirrored into the fork visibility artifacts. +- A fresh safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PRs are unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe branches (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`). +- Upstream issue `#145` now has an eighth repo-native partial mitigation on this branch. `backend/app/services/zep_tools.py` now builds entity-summary relations from the full graph edge set before canonical alias remapping, so `get_entity_summary()` no longer drops relations that are attached only to an alias node UUID such as `美国总统特朗普` when the canonical merged entity is `特朗普`. +- Validation for the latest entity-summary alias-edge mitigation passed with `cd backend && uv run pytest -q tests/test_zep_tools_dedup.py`, `python3 -m compileall backend/app/services/zep_tools.py`, and `bash ./scripts/test_backend_lite.sh`. +- Upstream issue `#145` now has a seventh repo-native partial mitigation on this branch. `backend/app/services/zep_tools.py` now applies the existing alias-collapse logic when computing graph statistics and resolving entity summaries, so obvious duplicate aliases no longer inflate `get_graph_statistics()` counts and alias queries such as `美国总统特朗普` resolve to the canonical merged entity summary/edge payload instead of missing the deduplicated node context. +- Validation for the latest graph-statistics/entity-summary alias-dedup mitigation passed with `cd backend && uv run pytest -q tests/test_zep_tools_dedup.py`, `python3 -m compileall backend/app/services/zep_tools.py`, and `bash ./scripts/test_backend_lite.sh`. +- Upstream issue `#145` now has a sixth repo-native partial mitigation on this branch. `backend/app/services/zep_tools.py` now remaps PanoramaSearch edges to the retained canonical node UUID/name after alias collapse, removes duplicate edge rows that previously survived through alias-specific UUIDs, and deduplicates repeated active/historical fact lines so the panorama output no longer repeats the same fact for obvious alias pairs such as `特朗普` and `美国总统特朗普`. +- Validation for the latest panorama alias-dedup mitigation passed with `cd backend && uv run pytest -q tests/test_zep_tools_dedup.py tests/test_zep_tools_i18n.py`, `python3 -m compileall backend/app/services/zep_tools.py`, and `bash ./scripts/test_backend_lite.sh`. +- Upstream issue `#145` now has a fifth repo-native partial mitigation on this branch. `backend/app/services/zep_tools.py` now collapses the same conservative alias pairs in QuickSearch/general search results and the local-search fallback, deduplicates repeated node-summary facts, and remaps duplicate edges to the retained canonical node UUID so report tooling and API consumers no longer see both `特朗普` and `美国总统特朗普` in the same search payload. +- Validation for the latest QuickSearch duplicate-alias mitigation passed with `cd backend && uv run pytest -q tests/test_zep_tools_dedup.py tests/test_zep_tools_i18n.py` and `bash ./scripts/test_backend_lite.sh`. +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T19:02:24.688287+00:00` for the open queue and `2026-03-11T19:02:35.020050+00:00` for the full history snapshot. The machine-readable summaries remain current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all `39` open PR refs, all `53` reviewed PR refs, all `41` open issues, and all `91` total issues mirrored into the fork visibility artifacts. +- A fresh safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PRs are unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe branches (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`). +- Upstream issue `#145` now has a fourth repo-native partial mitigation on this branch. `backend/app/services/graph_builder.py` collapses the same conservative alias pairs already handled in simulation inputs, report/search tooling, and the Process graph mapper when serving `/api/graph/data/<graph_id>`, and duplicate edges are remapped to the retained canonical node UUID so raw graph-data consumers no longer have to rely on frontend-only collapse logic. +- Validation for the latest duplicate-alias mitigation passed with `cd backend && uv run pytest -q tests/test_graph_builder.py`, `python3 -m compileall backend/app/services/graph_builder.py`, and `bash ./scripts/test_backend_lite.sh`. + +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T18:54:35.099071+00:00` for the open queue and `2026-03-11T18:54:57.794230+00:00` for the full history snapshot. The machine-readable summaries remain current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all `39` open PR refs, all `53` reviewed PR refs, all `41` open issues, and all `91` total issues mirrored into the fork visibility artifacts. +- A fresh safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PRs are unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe branches (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`). +- English-mode dual-platform interview fallbacks are less Chinese-first now: `backend/scripts/run_parallel_simulation.py` routes its remaining deterministic unavailable-platform, no-environment, platform-agent lookup, close-environment acknowledgement, and unknown-command strings through `backend/scripts/llm_env.py`, so the standalone parallel simulation runner no longer mixes inline Chinese fallback copy into English-mode IPC/error paths. +- Validation for this pass passed with `cd backend && uv run pytest -q tests/test_llm_env.py`, `python3 -m compileall backend/scripts/llm_env.py backend/scripts/run_parallel_simulation.py`, and `bash ./scripts/test_backend_lite.sh`. + +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T18:44:58Z` for the open queue and `2026-03-11T18:45:11Z` for the full history snapshot. The machine-readable summaries are still current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all `39` open PR refs and all `53` reviewed PR refs mirrored into `origin`. +- A fresh safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PRs are unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe branches (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`). +- English-mode retry diagnostics are less Chinese-first now: `backend/app/utils/retry.py` routes its deterministic sync/async/API retry and terminal-failure log lines through backend i18n, so OpenAI-compatible backend failures no longer emit mixed-language retry helper logs when `X-Locale=en`. +- Validation for this pass passed with `cd backend && uv run pytest -q tests/test_retry_i18n.py`, `python3 -m compileall backend/app/utils/retry.py backend/app/i18n.py`, and `bash ./scripts/test_backend_lite.sh`. + +- New upstream issue `#153` (`npm run setup:all` reports a `pillow==10.3.0` build failure) was ingested and mirrored into the fork as issue `#92`. The current branch does not reproduce that failure on the default core-install path: `cd backend && uv sync --frozen --python 3.13 --python-platform windows --dry-run --output-format json` no longer pulls `pillow` at all, which matches the repo-native split that keeps heavyweight simulation dependencies behind `setup:backend:simulation`. +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T18:42Z` for both the open and full snapshots. The machine-readable summaries remain current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all `39` open PR refs and all `53` historical PR refs mirrored into `origin`, plus all `91` upstream issues mirrored into the fork-visible issue summaries. +- A fresh safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PRs are unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe branches (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`). +- Upstream issue `#145` now has a third repo-native partial mitigation on this branch. `backend/app/services/zep_tools.py` collapses the same conservative alias pairs already used in simulation preparation and the Process graph view when building typed entity lists, Panorama output, and InsightForge entity/relationship summaries, so obvious title-prefixed duplicates such as `美国总统特朗普` versus `特朗普` no longer appear twice across Step 4/report-side analysis surfaces. +- Validation for the latest duplicate-alias mitigation passed with `cd backend && uv run pytest -q tests/test_zep_tools_dedup.py tests/test_zep_tools_i18n.py`, `python3 -m compileall backend/app/services/zep_tools.py`, and `bash ./scripts/test_backend_lite.sh`. +- The open upstream queue remains fully triaged after the March 11, 2026 `18:42 UTC` intake refresh: all `41` open issues are already covered in the local coverage map, all `39` open PR refs remain mirrored into `origin`, and no new clean upstream PR is safer than the existing repo-native partial-localization lane. +- English-mode interview control-plane logs are less Chinese-first now: `backend/app/services/simulation_runner.py` localizes the deterministic single-agent, batch, global, and close-environment IPC command log lines for `locale=en`, so Step 5 interview-trigger activity no longer writes mixed-language runner diagnostics into `simulation.log`. +- Validation for this pass passed with `cd backend && uv run pytest -q tests/test_simulation_service_i18n.py`, `python3 -m compileall backend/app/services/simulation_runner.py backend/app/i18n.py`, and `bash ./scripts/test_backend_lite.sh`. +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T18:28Z` for both the open and full snapshots. The machine-readable summaries remain current at `40` open issues / `39` open PRs and `90` total issues / `53` total PRs, with all `39` open upstream PR refs mirrored into `origin` and all `40` open upstream issues mirrored into the fork-visible summaries. +- A fresh safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PRs are unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe branches (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`). +- English-mode Step 2 profile generation is less Chinese-first now: `backend/app/services/oasis_profile_generator.py` localizes deterministic profile-generation progress updates, fallback/save logs, and console profile-output headers for `locale=en`, so simulation preparation no longer leaks mixed-language control-plane copy while generating agent profiles. +- Validation for this pass passed with `cd backend && uv run pytest -q tests/test_openai_compat_services.py`, `python3 -m compileall backend/app/services/oasis_profile_generator.py`, and `bash ./scripts/test_backend_lite.sh`. +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T18:25Z` for both the open and full snapshots. The machine-readable summaries remain current at `40` open issues / `39` open PRs and `90` total issues / `53` total PRs, with all `39` open upstream PR refs mirrored into `origin` and all `40` open upstream issues mirrored into the fork-visible summaries. +- A fresh safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. The remaining non-landed open PRs are unchanged: intentionally partial localization branches (`#119`, `#147`) plus broader unsafe branches (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`). +- English-mode interview fallback logs are less Chinese-first now: `backend/app/services/zep_tools.py` routes the remaining agent-selection, interview-question, and interview-summary fallback logger messages through the locale-aware helper, so English-mode Step 5 interview retries no longer emit mixed-language server logs when those deterministic fallback paths trigger. +- Validation for this pass passed with `cd backend && uv run pytest -q tests/test_zep_tools_i18n.py`, `python3 -m compileall backend/app/services/zep_tools.py`, and `bash ./scripts/test_backend_lite.sh`. +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T18:23Z` for both the open and full snapshots. The machine-readable summaries remain current at `40` open issues / `39` open PRs and `90` total issues / `53` total PRs, with all open upstream PR refs still mirrored into `origin` and the fork-visible issue mirrors preserved. +- English-mode simulation-config generation diagnostics are less Chinese-first now: `backend/app/services/simulation_config_generator.py` localizes deterministic `agents_per_hour` auto-adjust warnings plus initial-post assignment fallback/info logs for `locale=en`, which keeps config-generation console diagnostics aligned with the broader backend localization work without translating model-generated content. +- Validation for this pass passed with `cd backend && uv run pytest -q tests/test_openai_compat_services.py`, `python3 -m compileall backend/app/services/simulation_config_generator.py`, and `bash ./scripts/test_backend_lite.sh`. +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T18:12:09.379981+00:00` for the open queue and `2026-03-11T18:12:18.050563+00:00` for the full history snapshot. The machine-readable summaries are current at `40` open issues / `39` open PRs and `90` total issues / `53` total PRs, with all `39` open PR refs mirrored into `origin` and all `40` open upstream issues mirrored into the fork-visible summaries. +- New upstream PR `#152` (`support-pascal-and-snake-case`) is now mirrored into the fork as `origin/mirror/upstream-pr-152`. The branch is not safe to cherry-pick wholesale because it is based on an older tree that would drop current local tooling, i18n, tests, and upstream-triage artifacts, so a repo-native subset landed instead: `backend/app/services/graph_builder.py` now normalizes ontology entity type names to PascalCase, edge type names to SCREAMING_SNAKE_CASE, and `source_targets` references to those normalized entity names before calling Zep. +- Validation for the PR `#152` subset passed with `cd backend && uv run pytest -q tests/test_graph_builder.py`, `python3 -m compileall backend/app/services/graph_builder.py`, and `bash ./scripts/test_backend_lite.sh`. +- The open upstream queue remains fully triaged after the March 11, 2026 `18:03 UTC` intake refresh: no additional small PR is safe to cherry-pick, because every remaining open PR is already landed/superseded locally, intentionally partial (`#119`, `#147`), or broad enough to risk regressing newer backend/runtime work (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`). +- English-mode report chat is less brittle now even without new upstream intake. `backend/app/services/report_agent.py` localizes the deterministic Report Agent chat scaffolding for locale=`en`, including the no-report placeholder, truncated-report marker, tool-observation label, and concise-answer suffix, so the chat loop no longer feeds Chinese-only helper text back into an otherwise English conversation. +- Validation for this pass passed with `uv run --project backend pytest -q backend/tests/test_report_agent.py backend/tests/test_report_api_i18n.py`, `python3 -m compileall backend/app/services/report_agent.py`, and `bash ./scripts/test_backend_lite.sh`. +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T18:03:31.405734+00:00` for the open queue and `2026-03-11T18:03:42.058430+00:00` for the full history snapshot. The machine-readable summaries remain current at `40` open issues / `38` open PRs and `90` total issues / `52` total PRs, with all `38` open PR refs mirrored into `origin` and all `40` open upstream issues still mirrored into the fork-visible summaries. +- A fresh safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. Every remaining non-landed open PR is still either intentionally partial (`#119`, `#147`) or too broad to cherry-pick safely as-is (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`). +- Validation for this pass stayed green without additional code changes: `bash ./scripts/test_backend_lite.sh`, `npm --prefix frontend test`, and `npm --prefix frontend run build` all passed. +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T17:58:27.769917+00:00` for the open queue and `2026-03-11T17:58:39.850795+00:00` for the full history snapshot. The machine-readable summaries remain current at `40` open issues / `38` open PRs and `90` total issues / `52` total PRs, with all `38` open PR refs mirrored into `origin` and all `40` open upstream issues mirrored into the fork-visible summaries. +- A fresh safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. Every remaining non-landed open PR is still either intentionally partial (`#119`, `#147`) or too broad to cherry-pick safely as-is (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`). +- Step 3 waiting diagnostics are more English-friendly now: `SimulationIPCClient` localizes its deterministic command-sent / response-received / parse-failure / timeout log lines via backend i18n, so `simulation.log` tails no longer fall back to Chinese-only IPC diagnostics when locale=`en`. +- Validation also exposed and fixed a real OpenAI-compatible test flake: `backend/tests/test_print_config_status.py` now clears inherited `OPENAI_BASE_URL` in its subprocess env so the direct `OPENAI_API_BASE_URL` alias assertion stays deterministic even in shells that already export another compatible gateway. +- Validation for this pass passed with `cd backend && uv run pytest -q tests/test_print_config_status.py tests/test_simulation_service_i18n.py`, `python3 -m compileall backend/app/services/simulation_ipc.py backend/app/i18n.py`, and `bash ./scripts/test_backend_lite.sh`. +- Upstream intake refreshed again on March 11, 2026 at `2026-03-11T17:54:00.451753+00:00` for the open queue and `2026-03-11T17:54:10.588523+00:00` for the full history snapshot. The local machine-readable summaries are current at `40` open issues / `38` open PRs and `90` total issues / `52` total PRs, with all `38` open PR refs mirrored into `origin` and all `40` open upstream issues mirrored into the fork-visible summaries. +- A fresh safe-merge review after that refresh still did not expose a new clean upstream PR to adopt. Every remaining non-landed open PR is still either intentionally partial (`#119`, `#147`) or too broad to cherry-pick safely as-is (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`). +- Direct OpenAI-compatible backend verification is now covered end-to-end instead of only through config helpers: `backend/tests/test_print_config_status.py` now executes `scripts/print_config_status.py` in a subprocess with only `OPENAI_*` aliases plus `ZEP_API_KEY`, and asserts that the emitted config-status payload reports `llm.backend_mode = openai_compatible` with the expected alias sources. `npm run check:backend-config` was also re-run successfully against that exact environment. +- README parity for the direct Codex/OpenAI-compatible path is tighter now: the Japanese and Korean READMEs mention `npm run check:backend-config` alongside `/health` and `/api/graph/config/status`, and the Russian README now includes the same verification flow instead of only showing the environment variables. +- Upstream intake now has a repo-native sequential refresh entrypoint. `scripts/refresh_upstream_snapshots.sh` and `npm run sync:upstream` run the existing open/all GitHub snapshot refreshes one after the other with the repo's fork/coverage defaults, which avoids the repo-lock collision that occurs when both sync passes are started in parallel during evolve-style intake refreshes. +- Upstream intake refreshed again on March 11, 2026 and exposed new upstream issue `#150` ("Hardcoded 'reddit' platform default causes silent data loss for Twitter-only simulations"), already mirrored into the fork as issue `#91`. A repo-native backend fix is now landed locally: simulation profile/post retrieval resolves the active platform from `SimulationState` instead of silently assuming Reddit when Twitter/X is the only enabled platform, and `SimulationManager.get_profiles()` now reads Twitter CSV profile files correctly. Focused regression coverage passed with `cd backend && uv run pytest -q tests/test_simulation_service_i18n.py tests/test_simulation_api_i18n.py` plus `python3 -m compileall backend/app/services/simulation_manager.py backend/app/api/simulation.py`. +- Upstream intake was refreshed again on March 11, 2026 at `2026-03-11T17:08:44.856634+00:00` for the open queue and `2026-03-11T17:08:59.292574+00:00` for the full history snapshot. The local machine-readable summaries remain current at `39` open issues / `37` open PRs and `89` total issues / `51` total PRs, with all `37` open PR refs, all `51` reviewed PR refs, and all `89` upstream issues mirrored into the fork-visible summaries. +- Upstream intake was refreshed again on March 11, 2026 at `2026-03-11T17:30:44.920956+00:00` for the open queue and `2026-03-11T17:30:27.294078+00:00` for the full history snapshot. The counts are unchanged at `39` open issues / `37` open PRs and `89` total issues / `51` total PRs, with all `37` open PR refs and all `39` open upstream issues still mirrored into the fork-visible summaries. +- Upstream issue `#145` now has a second repo-native partial mitigation beyond the earlier simulation-input collapse. `frontend/src/views/processGraphData.js` now collapses the same conservative alias pairs while mapping the Process graph visualization, so obvious title-prefixed duplicates such as `美国总统特朗普` versus `特朗普` render as one node and duplicate edges are remapped to the retained canonical node without mutating the stored Zep graph. Regression coverage was added in `frontend/tests/processGraphData.test.mjs`, and the broader graph-level deduplication work remains tracked under beads issue `mirofish-975`. +- A fresh safe-merge review after that refresh still does not expose a new clean upstream PR to adopt. Every remaining open PR is still either already landed/superseded locally, intentionally partial (`#119`, `#147`), or too broad to cherry-pick safely as-is (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`). +- The current local validation baseline is green again: `cd backend && uv run pytest -q`, `npm --prefix frontend test -- --runInBand`, and `npm --prefix frontend run build` all pass on this branch, so the earlier targeted test regressions are no longer an active blocker. +- Upstream issue `#148` dropped out of the open queue between the `2026-03-11T16:58:32Z` and `2026-03-11T17:05:16Z` refreshes, so the latest intake no longer exposes it as actionable follow-up. The remaining open PR queue is otherwise unchanged: every open PR is still either already landed/superseded locally, intentionally partial (`#119`, `#147`), or too broad to cherry-pick safely as-is (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`). +- Beads-backed tracking was reconciled after the refresh: `mirofish-as6` no longer claims that upfront round configuration is still missing, and now tracks only the real remaining scope from upstream issue `#62` which is one-click Step 1-5 orchestration design beyond the already-landed Step 2 rounds control and Step 5 timeout-budget guidance. +- Upstream issue `#149` now has a concrete local mitigation. `SimulationRunner.get_run_state()` reconciles stale persisted `running`/`starting` states when the recorded worker PID is already gone, so Step 3 no longer polls forever against a dead worker after a backend restart or silent subprocess exit. `/api/simulation/<id>/run-status/detail` also now returns a compact `waiting_diagnostics` block with process liveness and a `simulation.log` tail, and `Step3Simulation.vue` renders that context directly under the existing "Waiting for agent actions" placeholder. +- Validation for the Step 3 stall mitigation passed with `cd backend && uv run pytest -q tests/test_simulation_runner_actions.py tests/test_simulation_run_status_detail.py`, `npm --prefix frontend test`, and `npm --prefix frontend run build`. +- Upstream intake was refreshed again on March 11, 2026 at `2026-03-11T16:30:58.162188+00:00` for the open queue and `2026-03-11T16:31:14.082952+00:00` for the full history snapshot. The local machine-readable summaries are current at `38` open issues / `37` open PRs and `87` total issues / `51` total PRs, with all `37` open PR refs and all `51` total PR refs mirrored into `origin`. +- The refreshed open-PR queue still does not expose a new safe merge or cherry-pick target. Every open PR is now either already landed/superseded locally, intentionally partial (`#119`, `#147`), or still too broad to adopt safely as-is (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`). +- Upstream issue `#145` now has a repo-native partial mitigation on this branch. `backend/app/services/zep_entity_reader.py` collapses conservative same-entity alias variants during filtered entity reads, so obvious title-prefixed duplicates such as `美国总统特朗普` versus `特朗普` no longer both flow into simulation/profile generation even though the stored Zep graph remains unchanged. Focused regression coverage was added in `backend/tests/test_zep_entity_reader.py`, and the remaining graph-level deduplication work stays tracked under beads issue `mirofish-975`. +- Validation for the duplicate-alias mitigation still passes with `cd backend && uv run pytest -q tests/test_zep_entity_reader.py tests/test_openai_compat_services.py` plus `bash ./scripts/test_backend_lite.sh`, and the broader repo-wide backend suite is green again via `cd backend && uv run pytest -q`. +- Upstream intake was refreshed again on March 11, 2026 at `2026-03-11T16:23:57.332401+00:00` for the open queue and `2026-03-11T16:24:09.501055+00:00` for the full history snapshot. The local machine-readable summaries remain current at `38` open issues / `37` open PRs and `87` total issues / `51` total PRs, with all `37` open PR refs and all `51` total PR refs mirrored into `origin`. +- `scripts/sync_upstream_github.py` now supports `--lock-wait-seconds`, so autonomous open/all refreshes can wait for the repo lock instead of failing immediately when another sync pass is already in flight. The sync script unit suite covers both the fail-fast path and the bounded wait path, which makes evolve-style intake refreshes less brittle without weakening the existing repo lock. +- The refreshed open-PR queue still does not expose a new safe merge or cherry-pick target. The remaining non-landed open PRs are unchanged: intentionally partial localization (`#119`, `#147`) or broader unsafe branches (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`). +- Upstream intake was refreshed again on March 11, 2026 at `2026-03-11T16:19:53.679930+00:00` for the open queue and `2026-03-11T16:20:07.875496+00:00` for the full history snapshot. The local machine-readable summaries are current at `38` open issues / `37` open PRs and `87` total issues / `51` total PRs, with all `87` upstream issues mirrored into fork issue summaries on `ivanzud/MiroFish`. +- New upstream PR `#147` was mirrored into the fork as `origin/mirror/upstream-pr-147`. The full Russian-localization branch is not safe to cherry-pick because it rewrites large frontend/backend areas and removes current local validation/tooling assets, but a safe repo-native docs subset landed locally as `README-RU.md` plus cross-links from the existing README variants. +- Upstream intake was refreshed again on March 11, 2026 at `2026-03-11T16:17:07.829452+00:00` for the open queue and `2026-03-11T16:17:20.437244+00:00` for the full history snapshot. The local machine-readable summaries remain current at `38` open issues / `36` open PRs and `87` total issues / `50` total PRs, with all `36` open PR refs mirrored into `origin` and all `87` upstream issues now annotated with fork issue mirror metadata for `ivanzud/MiroFish`. +- The refreshed queue still does not expose a new safe merge or cherry-pick target. The only non-landed open PRs remain the intentionally partial localization branch (`#119`) and the broader unsafe branches (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`), so the current blocker set is unchanged after the latest sync. +- Upstream intake was refreshed again on March 11, 2026 at `2026-03-11T16:10:44.278332+00:00` for the open queue and `2026-03-11T16:10:55.960341+00:00` for the full history snapshot. The local machine-readable summaries remain current at `38` open issues / `36` open PRs and `87` total issues / `50` total PRs, with all `36` open PR refs mirrored into `origin`. +- The refreshed open-PR queue still does not contain a new safe cherry-pick target. The remaining open PRs are unchanged: already landed or superseded locally, intentionally partial (`#119`), or still unsafe broad branches (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`). +- English-mode report console logs are now localized for deterministic report-agent output. `backend/app/services/report_agent.py` routes outline-planning, tool-execution, section-generation/save, full-report assembly, and report completion/failure logger messages through the active locale, and the helper `ReportManager` save/assemble paths now accept the locale so `console_log.txt` no longer leaks Chinese during Step 4 when `X-Locale=en`. +- Upstream intake was refreshed again on March 11, 2026 at `2026-03-11T15:53:05.941364+00:00` for the open queue and `2026-03-11T15:53:31.222576+00:00` for the full history snapshot. The local machine-readable summaries remain current at `38` open issues / `36` open PRs and `87` total issues / `50` total PRs, with all open and historical upstream issues mirrored into fork issue summaries on `ivanzud/MiroFish` and all `50` reviewed PR refs mirrored into `origin`. +- The latest open-PR review pass did not expose a new safe cherry-pick target. The remaining open PRs are either already superseded locally, intentionally partial (`#119`), or still unsafe broad branches (`#141`, `#144`, `#118`, `#108`, `#70`, `#38`, `#49`), so this pass shifted to a repo-native direct-backend verification improvement instead of forcing a stale merge. +- Direct Codex/OpenAI-compatible backend support is now visible in the app chrome as well as the docs. `ApiEndpointControl` now fetches `/api/graph/config/status` on demand and shows the resolved backend mode, active env-source family (`OPENAI_*` vs `LLM_*`), concrete env var names, effective base URL, and model, which makes it obvious when MiroFish is pointed at a direct OpenAI-compatible gateway without relying on provider-specific setup. +- Upstream intake was refreshed again on March 11, 2026 at `2026-03-11T15:48:53.159009+00:00` for the open queue and `2026-03-11T15:51:08.715215+00:00` for the full history snapshot. The local machine-readable summaries are current at `38` open issues / `36` open PRs and `87` total issues / `50` total PRs, with all `36` open PR refs and all `50` total PR refs mirrored into `origin`, and all `38` open upstream issues mirrored into fork issue summaries on `ivanzud/MiroFish`. +- New upstream issue `#146` requests Husky-based git hook checks, but the underlying need is already covered locally. The repo now ships an opt-in repo-native hook flow via `.githooks/pre-commit`, `.githooks/pre-push`, and `npm run hooks:install`, so the request is mirrored into fork issue [ivanzud/MiroFish#88](https://github.com/ivanzud/MiroFish/issues/88) as covered without taking on a Husky-only dependency. +- Upstream intake was refreshed again on March 11, 2026 at `2026-03-11T14:45:39.008852+00:00` for the open queue and `2026-03-11T14:45:31.057867+00:00` for the full history snapshot. The local machine-readable summaries are current at `37` open issues / `36` open PRs and `86` total issues / `50` total PRs, with all `36` open PR refs and all `50` total PR refs mirrored into `origin`. +- New upstream PR `#143` was mirrored into the fork as `origin/mirror/upstream-pr-143` and reduced to a safe repo-native docs fix. The incorrect `666ghj%2MiroFish` Shanda-logo alt text is now corrected to `666ghj%2FMiroFish` across all shipped README variants, and the coverage map records the PR as landed locally. +- New upstream PR `#144` was mirrored into the fork as `origin/mirror/upstream-pr-144`, but it is not safe to cherry-pick wholesale. The branch adds a large dual-mode graph backend and dependency stack on top of an older tree, so its intent stays tracked under beads issue `mirofish-8eg` instead of bypassing the current graph/simulation regression surface. +- Full-history upstream intake is more resilient now. `scripts/sync_upstream_github.py` retries transient direct-HTTP GitHub failures (`500/502/503/504`, timeout, and transport hiccups) with bounded backoff after `gh api` fallback, so a noisy comments/details fetch no longer aborts the whole `--state all` refresh on the first upstream `5xx`. +- Open upstream PR `#105` does not have a remaining safe cherry-pick delta. Its hardening goals are already covered locally: `SECRET_KEY` falls back to a temporary random value instead of a hardcoded secret, `FLASK_DEBUG` defaults to false, CORS parsing supports explicit origins/methods/headers, and shared API error helpers already suppress traceback leakage unless `DEBUG` is enabled. The remaining branch diff is tangled with older tree state, so this stays superseded locally rather than merged. +- Open upstream PR `#141` is mirrored into the fork as `origin/mirror/upstream-pr-141`, but it is not safe to cherry-pick wholesale on top of the current branch. The feature adds a large post-build entity-deduplication pipeline and rewinds broad parts of the tree; the follow-up beads task `mirofish-975` remains the correct place for any repo-native reimplementation with targeted graph-builder and Zep regression coverage. +- New upstream issue `#145` reports the same duplicate-entity-node behavior now visible in production graph builds (for example `特朗普` versus `美国总统特朗普`). The issue is mapped to beads task `mirofish-975`, mirrored into fork issue [ivanzud/MiroFish#2](https://github.com/ivanzud/MiroFish/issues/2), and still does not have a safe upstream code path beyond the broad unsafe PR `#141`. +- Open upstream issue `#24` is now covered locally and regression-tested. The current report agent retries empty section responses and, if the model still returns nothing, writes a per-section placeholder instead of bubbling a `NoneType` crash through the whole report-generation task. +- Open upstream issue `#106` still does not have a low-risk cherry-pickable fix in the reviewed PR queue. The remaining related branches are the stale local-graph work in `#49` and the broader RAGflow backend branch in `#118`, so this stays tracked locally as a backend-abstraction design task rather than a blind merge. +- Open upstream issue `#42` had a remaining local gap even after the earlier incremental polling hardening: Step 3 still kept an unbounded in-browser action buffer, and `/run-status/detail` still returned uncapped `recent_actions` for the current round. This pass closes that remaining low-risk memory-growth path without changing the simulation storage format. +- `#114` is already superseded locally. The current frontend base-url resolver still honors `VITE_API_BASE_URL`, falls back to the runtime origin for same-origin deployments, and keeps the repo-specific `3000 -> 5001` localhost fallback that the smaller upstream patch does not cover. +- `#70` is not safe to cherry-pick. Like the later installer branch `#108`, it adds a Windows-specific launcher/build pipeline that assumes a packaging flow outside the repo's current `run.py` + built-frontend topology and would need a repo-native redesign instead of a blind merge. +- `#38` is not a safe fit for this branch. It adds a second provider SDK/protocol (`Anthropic`) on top of a backend that already standardized on OpenAI-compatible APIs, and upstream already noted it should stay reference-only before a broader post-`v1.0` API-surface decision. +- `#49` is not safe to cherry-pick. It introduces a large local graph backend and memory-update stack without targeted regression coverage, and the newer upstream `#118` branch already supersedes it conceptually while still needing a dedicated backend-abstraction design pass. +- `#82` is not safe to cherry-pick on top of the current tree. That PR re-adds `unstructured==0.18.18` to the default backend requirements, but this branch intentionally split the optional simulation/runtime stack away from the core backend install so OpenAI-compatible graph/report usage does not pull `camel-oasis` / `unstructured` by default. +- `#101` is already superseded locally. Its JSON-cleanup intent is covered by the current `LLMClient` extraction/compatibility hardening, but the upstream branch predates newer config/i18n/test work and a blind cherry-pick would revert large parts of the current tree. +- `#100` is already superseded locally by the shared frontend API base-url resolver. The current client uses runtime-origin resolution plus the documented `3000 -> 5001` same-host fallback, so the older production-relative-only patch no longer adds unique value. +- `#102` is already subsumed locally by the landed ARM64 Docker workflow update. The current workflow already builds `linux/amd64,linux/arm64` images and also carries the newer Buildx/cache changes from the later workflow sweep. +- `#87` is already superseded locally by the landed GitHub Actions upgrade sweep (`#116`), so there is no remaining safe delta to cherry-pick from that older workflow-only PR. +- Open upstream issue `#68` now has a direct local mitigation: simulation process-exit errors classify common HuggingFace download/proxy failures into concise retry guidance instead of surfacing a raw backend log tail in Step 3. + +## Landed on this branch + +- Direct OpenAI-compatible backend verification is now documented explicitly: the README family now points users to `/health` plus `/api/graph/config/status`, which exposes the non-sensitive `llm.backend_mode=openai_compatible` status and the active `LLM_*` vs `OPENAI_*` env alias sources for Codex/OpenAI/DashScope-style gateways without requiring `LLM_PROVIDER`. +- The standalone runner env helper now synchronizes a full deterministic OpenAI-compatible alias set for direct Codex/OpenAI backends: it exports `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_API_BASE_URL`, and `OPENAI_MODEL`, and clears stale values when a later in-process run no longer provides them. Targeted tests cover both the new model alias propagation and stale-env cleanup path. +- `#81` Configurable frontend API timeout: low-risk support for slow local/OpenAI-compatible backends such as Ollama. +- `#104` Make Vite dev proxy target configurable with `VITE_API_BASE_URL`: removes another hardcoded localhost assumption for custom backend hosts and ports. +- `#115` Use SPDX license string: safe metadata-only cherry-pick. +- `#116` Upgrade GitHub Actions: safe workflow-only dependency bump. +- `#103` Upgrade Docker image workflow for ARM64 builds: safe workflow-only cherry-pick adding `linux/arm64` image output and GitHub Actions cache configuration. +- `#125` Improve new-project network error diagnostics: safe single-file frontend error-message improvement. +- `#122` Remove `response_format={"type":"json_object"}` from `chat_json()`: improves compatibility with LM Studio and Ollama-style backends. +- `#124` Robust JSON payload extraction: safe parsing hardening plus regression tests. +- Shared JSON payload extraction now also recovers wrapped top-level arrays from mixed model output, not just wrapped objects, which closes another low-risk OpenAI-compatible parsing edge case without changing provider contracts. +- `#127` Handle `None` response content: safe guard against provider edge cases. +- `#129` Safe subset landed locally: configurable `LLM_MAX_TOKENS`, automatic retry after context-length failures, and report-agent message pruning to reduce overflow crashes. +- `#131` Safe subset landed locally: Zep graph creation, ontology setup, and batch uploads now retry only transient failures (429/timeout/5xx-style cases) with bounded backoff, plus targeted regression tests. +- `#130` Add `CONTRIBUTING.md`: safe docs-only cherry-pick. +- `#132` Add README architecture overview: safe docs-only cherry-pick. +- `#112` Add Korean README: safe docs-only cherry-pick; normalized cross-links with the other language READMEs while landing it locally. +- `#113` Add Japanese README: safe docs-only cherry-pick; normalized cross-links with the other language READMEs while landing it locally. +- Upstream issue `#110` now has an explicit local answer in the shipped docs and env template: the backend accepts direct OpenAI-compatible gateways plus `OPENAI_*` aliases, and the README family now includes a verified Alibaba DashScope Coding Plan example (`https://coding.dashscope.aliyuncs.com/v1`, `qwen3.5-plus`) without requiring `LLM_PROVIDER`. +- Upstream issue `#99` is already covered locally by the landed ARM64 image workflow path: `.github/workflows/docker-image.yml` now builds `linux/amd64,linux/arm64`, so the fork no longer depends on a separate ARM-manifest follow-up. +- Upstream issue `#92` is already covered locally by the landed GitHub Actions upgrade sweep from `#116`, so the remaining upstream issue is stale relative to this branch. +- Upstream issue `#46` is already covered locally by the SPDX metadata cleanup from `#115`, so the setuptools `project.license` deprecation warning path is no longer present on this branch. +- `#73` Sanitize malformed ontology entity/edge items before fallback injection: prevents `_validate_and_process()` crashes on mixed-quality LLM JSON output. +- Upstream issue `#135` is now covered locally: `GraphBuilderService.set_ontology()` accepts string-valued ontology `attributes` items for both entity and edge definitions, so malformed LLM output no longer crashes graph builds with `TypeError: string indices must be integers`. +- `#74` Replace bare `except:` clauses with `except Exception:` in JSON repair and simulation history formatting paths. +- `#15` Handle failed simulation status in `Step3Simulation`: stop polling and surface an error instead of leaving the UI stuck in a running state. +- Upstream issue `#14` is therefore also covered locally: the frontend now detects `runner_status === "failed"` and surfaces the backend error instead of leaving Step 3 stuck in a perpetual running state. +- `#84` Failed report generation can now be retried directly from `Step4Report`: the view polls the persisted report status, surfaces backend error text when generation fails, and offers a `force_regenerate` retry path instead of leaving the user stranded on a dead report page. +- Upstream issue `#42` now has a direct local mitigation: Step 3 retains only a bounded live timeline window in the browser instead of accumulating every action indefinitely, and `/api/simulation/<id>/run-status/detail` now caps `recent_actions` for the active round. That keeps long simulations from growing frontend/API memory without limit while preserving incremental polling and on-disk action logs. +- Upstream issue `#45` now has a concrete Step 5 interaction fix locally: the frontend loads both Reddit and Twitter profile feeds, keeps platform metadata on each selected agent, and sends chat/survey interviews back to the matching platform instead of assuming a Reddit-only profile list. That makes Twitter-only simulations interactive again and removes ambiguous cross-platform targeting in dual-platform runs. +- Upstream issues `#37` and `#43` now have a concrete Step 5 mitigation locally: the interaction screen preflights `/api/simulation/env-status`, shows whether Reddit/Twitter interview backends are still alive, blocks requests against closed or unavailable platforms before they fail, and rewrites backend timeout/environment-closed errors into actionable guidance about reopening Step 3 or increasing Step 5 timeout budgets. +- `#105` Safe subset landed locally: API JSON error responses now use a shared helper that hides traceback details unless `DEBUG` is enabled, while still logging full tracebacks server-side; file-parser encoding fallbacks now emit debug logs instead of silently swallowing detector failures. +- `#126` Safe subset landed locally: backend config now exposes structured validation helpers and a non-sensitive config summary, while malformed numeric env vars no longer crash module import before validation can report them. +- `#119` Safe subset landed locally: the frontend now has a persisted `中文` / `English` language toggle for the home shell, main workflow header, and history modal, while API calls include `X-Locale` for future backend localization without forcing the larger upstream backend/UI refactor onto this branch. +- `#119` Safe subset landed locally again in this pass: the dedicated Step 2 / 3 / 5 route headers now also expose the same persisted language selector, so users can switch locales without backing out to the home or report shells mid-workflow. +- `#119` Safe subset landed locally again in this pass: the remaining `MainView` graph-build/project-load/manual-refresh workflow logs and fixed status strings now also come from the locale catalog, so English mode no longer falls back to mixed hardcoded strings during the Step 1 / Step 2 process shell. +- `#119` Safe subset landed locally again in this pass: Step 4 Insight/Panorama tool cards now parse both Chinese and English deterministic headings from report-agent tool output, so English-mode structured renders no longer silently collapse when backend/tool results use localized labels. +- The optional Zep graph-memory updater now also respects `locale=en` for deterministic runtime-generated activity text and platform labels, so English simulations no longer write Chinese-only episode summaries back into the graph-memory stream. +- Upstream issue `#138` now has a stronger local mitigation: the report-planning prompt explicitly forces readable, requirement-anchored titles and summaries, rejects abstract/literary phrasing, and requires concrete object naming for product/game/event/persona prediction reports; if the planner still returns an abstract/generic title, `ReportAgent.plan_outline()` now rewrites it to a requirement-anchored fallback title before the report is persisted. Targeted backend regression tests cover both the prompt constraints and the deterministic fallback. +- Upstream issue `#133` now has a direct local deployment diagnostic: the backend root path `/` no longer returns a bare 404, and `/`, `/health`, and `/healthz` all return a small JSON status payload listing the live API prefixes. That gives users a browser-visible confirmation that the Flask backend is up even before they hit a specific `/api/*` route. +- Upstream issue `#64` now also has a direct local upload-time diagnostic: `/api/graph/ontology/generate` returns a 400 with per-file error details for unsupported extensions, file-parse failures, and empty extracted text instead of collapsing those document-ingest failures into a generic 500. That makes server-side upload failures actionable even when the root cause is a missing parser dependency or a bad source document. +- Upstream issue `#68` now also has a clearer local runtime failure path: when the simulation subprocess dies while downloading HuggingFace models/resources, the backend recognizes the common network/proxy signature and returns a concise `huggingface.co` / proxy / VPN guidance message instead of exposing a raw process log tail in Step 3. +- Upstream issue `#139` now has a concrete local mitigation: graph-build task failures no longer persist raw traceback dumps into task polling/UI for common Zep auth problems. The graph builder now classifies 401/unauthorized/invalid-key failures into a concise `ZEP_API_KEY` guidance message, keeps full stack traces in server logs only, and preserves the original message for unrelated failures. +- Upstream issue `#139` now also has a follow-up hardening pass locally: when the Zep SDK embeds a traceback in the exception text for non-JSON `401 unauthorized` responses, the graph builder strips the traceback noise before building the task payload, still maps the failure to the concise `ZEP_API_KEY` guidance message, and adds regression coverage for both embedded-traceback auth failures and generic traceback sanitization. +- Follow-up localization work now also covers the graph-build process view and the Step 3 simulation monitor, so English mode is no longer limited to the shell/history surfaces while broader Step 4/5 and backend-message localization remains open. +- Follow-up localization work now also covers the Step 4 report-generation shell and report view chrome, including retry/error/status copy and the report-page header controls, so English mode stays coherent through report generation while deeper Step 4/5 content localization remains open. +- Follow-up localization work now also covers the Step 4 tool-result panels inside the report timeline, so Insight/Panorama/Interview/Quick Search labels, empty states, report header copy, and expand/collapse text now switch with the persisted `中文` / `English` locale instead of leaving hardcoded Chinese inside English mode. +- Follow-up localization work now also covers the shared graph panel and the Step 5 deep-interaction workspace chrome: Report Agent chat, agent-selection/survey controls, survey results, graph detail labels, and graph status hints all switch with the persisted `中文` / `English` locale instead of staying Chinese-first. +- Follow-up localization work now also covers the Step 2 environment-setup view, the Step 3 simulation-run shell, and the Step 5 interaction wrapper view logs/status chrome, so switching to English no longer drops back to hardcoded Chinese in those outer workflow containers while backend/model-generated content remains a separate follow-up. +- Follow-up localization work now also covers deterministic Step 2 / Step 3 system-log copy: prepare-stage progress logs now map known stage codes through the locale catalog, and Step 3 PID / per-platform round progress lines use shared localized formatters instead of leaking hardcoded Chinese-adjacent shell strings in English mode. +- Follow-up localization work now also covers the remaining hardcoded Step 1/Step 2 workflow labels that bypassed the locale catalog, including ontology detail badges/section headers, generated type headers, Step 2 info-card labels, and deterministic profile-name fallback copy. +- Follow-up localization work now also covers deterministic backend config and Step 1 graph-build API errors: the backend reads the existing `X-Locale` header, translates fixed validation/error strings to English when requested, and keeps Chinese as the default for existing clients. That reduces one of the remaining backend-generated Chinese seams without attempting to machine-translate model-generated report/interview content. +- Follow-up localization work now also covers deterministic report API payloads: report generation/status/detail/chat/section/tool endpoints translate fixed validation, not-found, and progress-status strings via `X-Locale`, so English mode no longer drops back to hardcoded Chinese when the report shell hits backend-driven error/status responses. +- Follow-up localization work now also covers locale-aware backend prompt construction for generated content. English mode is threaded into `ReportAgent`, `OntologyGenerator`, and `OasisProfileGenerator`, so report outlines/sections/chat answers, ontology `analysis_summary`, and Step 2 persona-generation prompts no longer hardcode Chinese output requirements when the client requests English. +- Follow-up localization work now also covers Step 3 simulation-config generation. The prepare flow threads `X-Locale` into `SimulationManager`, `OasisProfileGenerator`, and `SimulationConfigGenerator`, so English mode no longer falls back to Chinese for simulation-config prompts, fallback reasoning text, or missing-key validation during Step 2/3 preparation. +- Follow-up localization work now also covers deterministic ZEP-backed backend seams for English mode: the Step 2 entity APIs translate missing `ZEP_API_KEY` and missing-entity responses via `X-Locale`, and the ZEP-backed service constructors now honor request locale for fixed missing-key errors instead of always falling back to Chinese. +- Follow-up localization work now also covers the remaining deterministic simulation API status payloads in English mode: create/prepare/prepare-status/profile generation/interview validation/env-status/close-env now translate fixed response strings via `X-Locale`, `_check_simulation_prepared()` localizes its helper reasons, and `SimulationRunner.close_simulation_env()` accepts locale so close-environment responses no longer leak Chinese payloads into the English UI. +- OpenAI-compatible backend aliases now work in the standalone simulation runners too, so `OPENAI_API_KEY` / `OPENAI_BASE_URL` / `OPENAI_MODEL` can be used directly outside the Flask app path. +- Objective 7 verification status: backend config, standalone runners, and both READMEs now explicitly support direct OpenAI / Codex-compatible / OpenAI-compatible backends without requiring a project-specific raw-key-only setup. +- Quick-start docs and `.env.example` now also include explicit OpenAI / Codex-compatible and Alibaba DashScope Coding Plan examples, plus a note that no separate `LLM_PROVIDER` flag is required when using OpenAI-compatible backends. +- Backend config now also accepts `OPENAI_API_BASE_URL`, matching the environment variable exported by the standalone simulation runners and some OpenAI-compatible tooling, with regression coverage in the lightweight backend test path. +- Standalone simulation runners now also honor `OPENAI_API_BASE_URL` directly on input, so direct Codex/OpenAI-compatible backend setups work consistently in both the Flask app and the CLI simulation entry points. +- The shared runner env helper now also exposes the standard `OPENAI_MODEL` alias directly, and the parallel simulation boost path uses that alias when no boost-specific model is configured, so Codex/OpenAI-compatible setups stay consistent even in mixed standard/boost runner configurations. +- The shared runner env helper now also exports both `OPENAI_BASE_URL` and `OPENAI_API_BASE_URL` for downstream tooling, so standalone simulation runs stay compatible with OpenAI/Codex gateways and libraries that expect either base-url alias on process env. +- Upstream issue `#32` is now covered end-to-end locally: when an OpenAI-compatible backend rejects `response_format={"type":"json_object"}`, `OasisProfileGenerator` and `SimulationConfigGenerator` retry without JSON mode and still parse the returned JSON payload. That closes the remaining high-signal backend compatibility gap for direct Codex/OpenAI-compatible setups. +- Generator/service missing-key errors now also mention `OPENAI_API_KEY` explicitly instead of implying only `LLM_API_KEY` works, which better matches the supported direct OpenAI-compatible backend path. +- Upstream issue `#52` is now covered locally by the landed report-agent overflow hardening from `#129`: report generation trims oversized message history, retries on context-length errors, and exposes configurable `LLM_MAX_TOKENS` so smaller-context self-hosted models do not crash as soon as prompt history crosses the model limit. +- Upstream issue `#117` is covered locally for the shipped UI flow: the repo now ships English README docs, a persisted `中文` / `English` UI toggle across the major workflow chrome, localized deterministic backend status/error payloads, locale-aware prompt construction for deterministic generated-content seams, and Step 4 parser helpers that accept both Chinese and English interview/search tool-output markers plus no-reply placeholders. Remaining polish is mostly runtime/model-authored content, which stays tracked in local beads follow-ups instead of reopening the stale upstream branch wholesale. +- `#114` Fix API base URL fallback is already superseded locally by the current frontend API client, which now falls back to the runtime origin and also supports `VITE_API_TIMEOUT`. +- `#93` Hardcoded frontend API base URL is now fully addressed locally: `Process.vue` uses the shared frontend API resolver instead of embedding a separate `http://localhost:5001` fallback in network-error messages. +- Upstream issue `#133` is now addressed locally: when the frontend is served on the documented default port `3000` without an explicit `VITE_API_BASE_URL`, it now auto-targets backend port `5001` on the same host instead of calling the frontend origin and failing in dual-port Docker/local deployments. The READMEs now also point users to `http://localhost:5001/health` instead of expecting the backend root to serve a page. +- Upstream issue `#64` now has a clearer server-deployment failure path locally: before ontology generation or graph building starts, the frontend performs a backend config preflight and surfaces the exact missing `LLM_*` / `OPENAI_*` / `ZEP_API_KEY` validation errors instead of collapsing them into a generic upload/build 500. +- Upstream issue `#64` now also has the same protection on the direct backend routes: `/api/graph/ontology/generate` and `/api/graph/build` reuse the structured config validation payload from `/api/graph/config/status`, so scripts/reverse proxies/API clients that bypass the frontend still get a deterministic 503 with non-sensitive validation details instead of a late runtime failure. +- Upstream issue `#42` now has an incremental Step 3 polling path locally: the detailed run-status API returns a bounded recent window on first load and then only actions at or after the newest seen timestamp, so the frontend no longer re-downloads the entire simulation timeline every 3 seconds while a run grows. +- Upstream PR `#82` is now partially resolved locally via isolation instead of a blind dependency bump: the default backend install no longer pulls `camel-oasis` / `unstructured`, OASIS runtime packages moved behind an explicit `simulation` extra / `requirements-simulation.txt`, and simulation startup now fails fast with an install hint when those optional packages are absent. +- Follow-up on `mirofish-0kl`: the optional simulation install path now vendors the upstream `oasis` package under `backend/oasis` and replaces `camel-oasis` with explicit runtime dependencies, so `backend/uv.lock` no longer contains `camel-oasis` or `unstructured`. +- Upstream issues `#43` and `#58` now have a concrete timeout-control path locally: Step 5 interviews derive a backend timeout override from the configured frontend request window, the backend exposes dedicated `INTERVIEW_*_TIMEOUT_SECONDS` defaults for direct API callers, and the docs/.env example now show how to raise those limits for slow local models instead of hard-failing at the old 60/120-second server defaults. +- `#101` Robust JSON helper utilities are now mirrored into the fork for visibility, but the upstream branch predates substantial local/frontend/backend hardening on this branch; a blind cherry-pick would effectively revert large amounts of newer work, and the useful intent is already covered locally by broader JSON payload extraction and compatibility fixes. +- Upstream question issue `#69` now has an explicit local answer in both READMEs: the project supports direct OpenAI-compatible backends via either `LLM_*` or `OPENAI_*` env vars, with concrete examples for OpenAI/Codex-compatible gateways, DashScope compatible mode, DashScope Coding Plan, LM Studio, and Ollama-style gateways. +- Upstream question issue `#21` now also has explicit local docs/UI guidance: refreshing or closing the browser does not by itself stop backend jobs, persisted runs remain reopenable from history for Step 1 / 2 / 4, and Step 3 / 5 still require a live runtime session rather than true historical playback. +- Upstream issue `#9` now has a stronger local Step 3 recovery path: the simulation view no longer force-restarts on mount by default, it first reattaches to an existing run/timeline when one exists, destructive log cleanup only happens through the explicit restart control, and the history modal now exposes a replay-only Step 3 entry that loads prior run state without accidentally auto-starting a fresh simulation. That makes refresh/navigation safer for in-progress, failed, and completed simulation runs even though true mid-run checkpoint/resume is still outside the current backend design. + +## Deferred for later review + +- `#105` is now fully landed locally: backend security/config hardening includes env-driven CORS controls with a localhost-only default allowlist, `DEBUG=False` by default, generated fallback `SECRET_KEY` behavior, and regression coverage for both default and override paths. +- `#119` Remaining scope is still deferred: backend/model-generated content and other runtime payloads can still arrive in Chinese even though the deterministic frontend process-shell logs are now localized, so broader localization should be handled as a follow-up instead of continuing to splice a large, drifting upstream PR into this branch. +- `#119` remains not worth cherry-picking wholesale after another direct diff review: the upstream branch still rewinds large swaths of newer repo-native work, while the remaining safe local deltas are better landed as narrow localization seams like the Step 2 / Step 3 system-log formatter pass from this evolve cycle. +- After the latest Step 5 / graph-panel localization pass, the main remaining localization gaps are backend-generated content, agent/profile payload text, and other runtime data that arrives in Chinese from the backend or models rather than from frontend chrome. +- `#108` Windows installer packaging is now mirrored into the fork for visibility, but it remains a large Windows-specific feature addition (`installer/build.ps1`, Inno Setup flow, release packaging) and is not a safe blind cherry-pick for this branch. +- `#118` RAGflow backend support is now mirrored into the fork for visibility, but it is a large dual-backend feature branch touching graph APIs, config, and simulation services, so it needs a dedicated design/review pass instead of a low-risk merge. +- `#108` review outcome on March 11: the proposed Windows packaging flow is currently not repo-compatible as written. It starts the backend with `app.py` instead of the existing `run.py` entry path, launches the frontend dev server instead of serving the built frontend, and rebundles the already-landed ARM64 Docker workflow change from `#103`, so it should be treated as a fresh packaging design task rather than cherry-picked. +- `#118` review outcome on March 11: the RAGflow branch is not safe to land as-is because it introduces a second graph backend across build/read/simulation/delete paths without targeted regression tests, assumes specific RAGflow API response shapes, and would need careful rebasing against the newer local config-validation and API-error handling work already on this branch. +- `#87` and `#86` GitHub Actions-only PRs are superseded locally by the current Docker workflow: their diffs would either partially duplicate already-landed upgrades or regress this branch by removing the ARM64/cache changes that came from `#103`. +- `#100` Relative frontend API base URL fallback is superseded locally by the current API client, which already falls back to the runtime origin and respects `VITE_API_BASE_URL`. +- `#72` Markdown-fence cleanup for JSON responses is superseded locally by the broader `_extract_json_payload()` handling in `backend/app/utils/llm_client.py`. +- `#70` Windows installer packaging remains deferred to a repo-native packaging design task instead of either upstream installer branch. +- `#38` Anthropic SDK support remains deferred unless the project intentionally expands beyond OpenAI-compatible backends; the current branch keeps the smaller compatibility surface and documents direct OpenAI/Codex-compatible usage instead. +- `#49` Local graph-memory backend work remains deferred behind the broader graph-backend abstraction/design follow-up instead of landing a stale monolithic backend branch. +## Validation status + +- `cd backend && uv run pytest -q tests/test_report_agent.py tests/test_report_api_i18n.py` passes after localizing deterministic report-agent logger output for English-mode `console_log.txt`. +- `bash ./scripts/test_backend_lite.sh` passes after threading locale through the `ReportManager` save/assemble logging paths and adding focused console-log regression coverage. +- `python3 -m unittest tests/test_sync_upstream_github.py` passes for the GitHub sync script pagination/state summary logic. +- `python3 -m unittest tests/test_sync_upstream_github.py` passes after adding a `gh api` fallback path, and the sync script now refreshes upstream snapshots successfully in this environment even when anonymous GitHub API requests are rate-limited. +- `cd frontend && npm run build` passes after landing `#104` and the prior OpenAI-alias compatibility updates. +- `cd frontend && npm run build` passes after landing `#15`. +- `cd frontend && npm run build` passes after adding failed-report retry handling in `Step4Report` for upstream issue `#84`. +- `npm run test:backend:lite` now provides a repo-native lightweight backend validation path when full `uv` resolution is blocked by Rust/CUDA-heavy dependencies. +- `npm run test:backend:lite` passes with the `OPENAI_API_BASE_URL` regression test plus the new structured config-validation coverage included in the default lightweight backend suite. +- `npm run test:backend:lite` passes after adding standalone-runner alias regression coverage for `backend/scripts/llm_env.py`, confirming the CLI simulation entry points now accept `OPENAI_API_BASE_URL` directly. +- `npm run test:backend:lite` now also covers the JSON-mode compatibility fallback tests for `OasisProfileGenerator` and `SimulationConfigGenerator`, confirming those generators keep working with OpenAI-compatible backends that reject `response_format=json_object`. +- `cd backend && .venv/bin/python -m pytest -q tests/test_report_agent.py` passes after adding the report-outline title fallback, covering both the prompt-level readability constraints and the deterministic requirement-anchored fallback for abstract titles. +- `./.tmp-test-venv/bin/pytest backend/tests/test_error_handler.py backend/tests/test_llm_client.py backend/tests/test_graph_builder.py backend/tests/test_ontology_generator.py -q` passes after landing the safe subset of `#105`. +- `./.tmp-test-venv/bin/pytest backend/tests/test_llm_client.py backend/tests/test_graph_builder.py -q` passes with targeted regression coverage for context-length handling and transient Zep retry behavior. +- `./.tmp-test-venv/bin/pytest backend/tests/test_ontology_generator.py backend/tests/test_llm_client.py backend/tests/test_graph_builder.py -q` passes after landing the ontology validation hardening and exception-scope cleanup. +- `./.tmp-test-venv/bin/pytest backend/tests/test_graph_builder.py -q` passes after hardening `set_ontology()` to normalize string-valued ontology attributes from loose LLM output, covering the new upstream issue `#135` traceback shape. +- `python3 -m unittest tests/test_sync_upstream_github.py` passes after teaching the sync script to hydrate per-PR details, so the local JSON/markdown snapshots now include real `mergeable_state` metadata instead of `unknown` placeholders. +- `python3 -m unittest tests/test_sync_upstream_github.py` passes after extending the sync script to persist compact `body_excerpt`, `comment_count`, and `recent_comments` fields for issues/PRs, and the markdown summaries now surface those excerpts inline for faster manual triage. +- `python3 scripts/sync_upstream_github.py --state open ...` and `--state all ...` refreshed the local snapshots again on March 11, 2026; the latest captures now show `36` open upstream issues, `33` open upstream PRs, and `14` closed upstream PRs (`47` total PRs and `83` total issues in the full snapshot, with `34/47` PR refs mirrored into the fork). +- `python3 scripts/sync_upstream_github.py --state open|all --fork-remote origin ...` now annotates each PR record with `fork_mirrored` / `fork_mirror_ref`; after mirroring the remaining open PR refs, the refreshed snapshots show `33/33` open PRs and `34/47` total PRs mirrored into the fork, with the remaining non-mirrored refs limited to older closed PR history. +- `python3 -m unittest tests/test_sync_upstream_github.py` passes after extending the snapshot schema with `head_sha`, `head_repo`, `head_clone_url`, and `base_repo`, so ambiguous same-name upstream branches such as `main` can still be mirrored and traced back to the contributor fork deterministically. +- `python3 -m unittest tests/test_sync_upstream_github.py` passes after adding retry/fallback handling for transient `gh api` failures during PR hydration, and both `--state open` and `--state all` refreshes now complete successfully again in this environment. +- `python3 -m unittest tests/test_sync_upstream_github.py` passes after adding bounded request/subprocess timeouts to `scripts/sync_upstream_github.py`, and both `python3 scripts/sync_upstream_github.py --state open --fork-remote origin --timeout 15 ...` and `--state all --fork-remote origin --timeout 15 ...` completed successfully on March 11, 2026 instead of hanging indefinitely under this environment's slower GitHub round-trips. +- `python3 -m unittest tests/test_sync_upstream_github.py` passes after adding a fresh-cache fallback for GitHub API rate limits; when GitHub returns `403 rate limit exceeded`, the sync script now reuses a matching recent `docs/upstream-*.json` snapshot and regenerates the markdown summary instead of aborting the whole evolve pass. +- `cd frontend && npm test` passes with new coverage for the frontend API base URL resolver, including the default `3000 -> 5001` dual-port deployment fallback. +- `cd frontend && npm run build` passes after restoring dual-port frontend/backend compatibility for the default local and Docker topology. +- `cd frontend && npm test -- --runInBand` and `cd frontend && npm run build` both pass after localizing the shared graph panel and Step 5 deep-interaction chrome. +- `npm --prefix frontend test` and `npm --prefix frontend run build` both pass after making Step 5 profile loading and interview targeting platform-aware. +- `cd frontend && npm test` and `cd frontend && npm run build` both pass after localizing the Step 2/3/5 workflow wrapper views and their runtime log/status copy. +- `cd frontend && npm test` and `cd frontend && npm run build` both pass after localizing deterministic Step 2 prepare-progress logs and Step 3 PID / round-progress monitor lines through shared frontend helpers. +- `npm --prefix frontend test -- --runInBand` and `npm --prefix frontend run build` both pass after localizing the remaining Step 4 tool-result panel chrome in the report timeline. +- `npm run test:backend:lite`, `cd frontend && npm test`, and `cd frontend && npm run build` all pass after adding the backend config-status preflight endpoint plus frontend API-error preservation for the remaining issue `#64` server-deployment diagnostics path. +- `npm run test:backend:lite`, `cd frontend && npm test`, and `cd frontend && npm run build` all pass after narrowing Step 3 simulation polling to a bounded initial window plus incremental action fetches for the remaining issue `#42` memory-growth path. +- `cd frontend && npm test` and `cd frontend && npm run build` pass after changing `Step3Simulation` to probe existing run state before auto-starting, so refresh/navigation now reattaches to active or prior simulation timelines instead of blindly calling `/api/simulation/start` with `force=true`. +- `npm run test:backend:lite`, `npm --prefix frontend test`, and `npm --prefix frontend run build` all pass after adding configurable Step 5 interview timeout handling for slower/local backends, including backend config validation coverage plus frontend timeout-scaling unit tests. +- `npm --prefix frontend test` and `npm --prefix frontend run build` both pass after adding Step 5 interview-environment preflight/error normalization for the remaining interaction issues `#37` and `#43`. +- `npm --prefix frontend test` and `npm --prefix frontend run build` both pass after adding the shared `LanguageSelector` to the dedicated Step 2 / 3 / 5 route headers, which closes another low-risk remainder from upstream PR `#119`. +- `npm --prefix frontend test` and `npm --prefix frontend run build` both pass after localizing the remaining deterministic `MainView` workflow/build logs and adding `frontend/tests/mainViewLogMessages.test.mjs`, which closes another narrow safe subset of upstream PR `#119` without rewinding newer frontend work. +- `npm --prefix frontend test` and `npm --prefix frontend run build` both pass after moving Step 4 Insight/Panorama parsing into shared bilingual report parsers and adding `frontend/tests/reportParsers.test.mjs` coverage for both Chinese and English tool-output headings. +- `bash ./scripts/test_backend_lite.sh` now also covers `backend/tests/test_llm_env.py` and the OpenAI-compatible service constructor error paths; it passes after the latest alias cleanup, confirming `OPENAI_MODEL` fallback and alias-aware missing-key messaging in the direct OpenAI/Codex-compatible backend flow. +- `bash ./scripts/test_backend_lite.sh` passes after exporting both OpenAI base-url aliases from `backend/scripts/llm_env.py`, confirming the standalone runner helper keeps `OPENAI_BASE_URL` and `OPENAI_API_BASE_URL` in sync for direct Codex/OpenAI-compatible backend setups. +- `uv run --project backend pytest -q backend/tests/test_llm_client.py backend/tests/test_openai_compat_services.py` and `bash ./scripts/test_backend_lite.sh` pass after hardening `LLMClient._extract_json_payload()` to recover wrapped top-level JSON arrays from mixed model output. +- `bash ./scripts/test_backend_lite.sh` passes after adding backend route coverage for `/`, `/health`, and `/healthz`, confirming the new deployment-diagnostics endpoint stays available in the lightweight backend path without dragging in the optional runtime stack. +- `npm --prefix frontend test`, `bash ./scripts/test_backend_lite.sh`, and `cd backend && uv run pytest -q tests/test_simulation_runner_actions.py tests/test_simulation_run_status_detail.py` pass after bounding the Step 3 live action buffer and the `recent_actions` payload in `/run-status/detail`. +- `bash ./scripts/test_backend_lite.sh` passes after adding upload-diagnostic regression coverage for `/api/graph/ontology/generate`, confirming the lightweight backend path now surfaces structured per-file upload errors instead of a generic 500 for parse/validation failures. +- `./.tmp-test-venv/bin/python -m pytest backend/tests/test_graph_upload_api.py backend/tests/test_config.py` passes after hardening the direct graph API config-validation path, confirming the new 503 diagnostics and the existing upload-validation cases still behave as expected. +- `npm run test:backend:lite` passes after adding backend locale regression coverage for config validation, request-header locale detection, and the OpenAI-compatible missing-key helper. +- `cd backend && uv run pytest -q tests/test_report_agent.py tests/test_ontology_generator.py tests/test_openai_compat_services.py` passes after threading locale into the report/ontology/profile prompt builders, confirming English mode now reaches those generated-content prompt seams without regressing existing zh defaults. +- `bash ./scripts/test_backend_lite.sh` passes after the locale-aware prompt update, keeping the lightweight backend regression suite green. +- `bash ./scripts/test_backend_lite.sh` passes after isolating the optional OASIS runtime and adding a simulation-runner guard test, confirming the lightweight backend path remains healthy without installing `camel-oasis` or `unstructured`. +- `cd backend && uv sync --frozen && uv run python - <<'PY' ...` confirms the default backend environment on March 11, 2026 does not install `camel`, `oasis`, or `unstructured`; those packages now appear only when explicitly opting into the optional simulation runtime. +- `bash ./scripts/test_backend_lite.sh` passes after vendoring `backend/oasis` and removing `camel-oasis` from the simulation manifests; the lightweight suite now also guards against reintroducing that meta-package. +- `cd backend && uv lock` succeeds after the simulation-manifest cutover and removes `camel-oasis`, `unstructured`, and related unused transitive packages from `backend/uv.lock`. +- `cd backend && uv run pytest -q` is currently blocked in this environment because dependency resolution reaches `tiktoken`, which attempts a source build and fails without a Rust compiler. +- `cd backend && uv sync --extra simulation --frozen` is still blocked on March 11, 2026 in this Python 3.13 environment because `camel-ai` pulls `tiktoken==0.7.0`, which falls back to a source build and fails without a Rust compiler; the blocker is now independent of `camel-oasis` / `unstructured`. +- `python3 -m unittest tests/test_setup_backend_simulation.py` passes after adding a repo-level simulation-install guard that explicitly blocks Python 3.13+ without Rust before `uv sync --extra simulation` runs. +- `python3 scripts/setup_backend_simulation.py` now fails fast on this March 11, 2026 Python 3.13 environment with a direct Python-version/Rust prerequisite message instead of disappearing into the downstream `camel-ai -> tiktoken` source-build failure. +- `npm --prefix frontend test -- --runInBand` and `npm --prefix frontend run build` both pass after documenting the current backend-compatibility and browser-refresh recovery behavior in the README plus localized history modal hint copy. +- `./.tmp-test-venv/bin/pytest backend/tests/test_simulation_api_i18n.py backend/tests/test_i18n.py -q` passes after localizing the remaining simulation API status/validation payloads and extending the test file with English-mode coverage for prepare-status, profile generation, batch interview validation, env-status, and close-env. +- `bash ./scripts/test_backend_lite.sh` now includes `backend/tests/test_simulation_api_i18n.py` and passes with `51` tests after the simulation-API localization pass, keeping the lightweight backend suite aligned with the new deterministic i18n seam. +- `cd backend && uv run pytest -q tests/test_openai_compat_services.py tests/test_simulation_runner_actions.py` and `bash ./scripts/test_backend_lite.sh` pass after threading locale into the optional Zep graph-memory updater, covering both English episode-text generation and simulation-start locale propagation. + +## Snapshot artifacts + +- `docs/upstream-open-state.json` and `docs/upstream-open-summary.md` remain the fast open-work triage view. +- `docs/upstream-all-state.json` and `docs/upstream-all-summary.md` now capture the full upstream issue/PR state for historical triage and mirroring decisions. +- On March 11, 2026 both the full-state snapshot (`2026-03-11T09:57:42Z`) and the open-only snapshot (`2026-03-11T10:01:13Z`) refreshed successfully with explicit request timeouts, capturing new upstream issue `#138` and keeping the open-work intake current without manual GitHub browsing. +- The open-only snapshot refreshed again on March 11, 2026 at `2026-03-11T10:06:54Z`; a same-pass full-state refresh hit the shell timeout wrapper, so the last successful full snapshot remains `2026-03-11T10:02:53Z` until the next sequential refresh completes. +- The full-state refresh is stable again after switching PR-detail and comment hydration to a bounded worker pool; `python3 scripts/sync_upstream_github.py --state all --fork-remote origin --timeout 15 --max-workers 8 ...` completed successfully on March 11, 2026 at `2026-03-11T10:10:59.225029+00:00` and refreshed the 83-issue / 47-PR snapshot without hitting the shell timeout wrapper. +- The latest open-only snapshot refreshed successfully on March 11, 2026 at `2026-03-11T10:13:03.573340+00:00`, and the latest full-state snapshot refreshed successfully at `2026-03-11T10:13:20.919322+00:00`; the full pass briefly hit GitHub CLI rate limiting, but the sync script degraded to direct HTTP and still completed. +- The open-only snapshot refreshed again on March 11, 2026 during this evolve pass, and the full-state snapshot refreshed again immediately afterward; the local machine-readable intake is current again at `36` open issues / `33` open PRs and `83` total issues / `47` total PRs. +- The latest open-only snapshot refreshed again on March 11, 2026 at `2026-03-11T10:28:52.646163+00:00`, and the latest full-state snapshot refreshed again at `2026-03-11T10:29:20.184446+00:00`; fork mirror visibility remains current for all `33` open PR refs and `34` of `47` total PR refs in the historical full snapshot. +- The latest open-only snapshot refreshed again on March 11, 2026 at `2026-03-11T10:33:31.994836+00:00`, and the latest full-state snapshot refreshed again at `2026-03-11T10:33:49.623074+00:00`; the local machine-readable intake remains current at `36` open issues / `33` open PRs and `83` total issues / `47` total PRs. +- The latest open-only snapshot refreshed again on March 11, 2026 at `2026-03-11T10:41:22.684718+00:00`, and the latest full-state snapshot refreshed again at `2026-03-11T10:44:08.799553+00:00`; the local machine-readable intake remains current at `36` open issues / `33` open PRs and `83` total issues / `47` total PRs. +- The latest open-only snapshot refreshed again on March 11, 2026 at `2026-03-11T10:47:42.888405+00:00`, and the latest full-state snapshot refreshed again at `2026-03-11T10:47:58.339463+00:00`; the local machine-readable intake still shows `36` open issues / `33` open PRs and `83` total issues / `47` total PRs, so this pass shifted from intake refresh to pruning the remaining clean-but-stale PR queue. +- The latest open-only snapshot refreshed again on March 11, 2026 at `2026-03-11T10:50:26.842374+00:00`, and the latest full-state snapshot refreshed again at `2026-03-11T10:50:44.691201+00:00`; the local machine-readable intake still shows `36` open issues / `33` open PRs and `83` total issues / `47` total PRs, and this pass finished reviewing the remaining untriaged open PRs (`#70`, `#38`, `#49`) as unsafe to cherry-pick. +- The latest open-only snapshot refreshed again on March 11, 2026 at `2026-03-11T10:55:58.515531+00:00`, and the latest full-state snapshot refreshed again at `2026-03-11T10:56:39.783557+00:00`; the local machine-readable intake now shows `34` open issues / `33` open PRs and `82` total issues / `47` total PRs, which indicates upstream issue churn since the prior pass while keeping all `33` open PR refs mirrored into the fork. +- The latest open-only snapshot refreshed again on March 11, 2026 at `2026-03-11T11:06:24.089784+00:00`; the sequential full-state refresh hit GitHub API rate limiting, so the sync script reused the fresh cached full snapshot from `2026-03-11T10:59:29.299684+00:00` instead of failing the evolve pass. Intake remains current at `34` open issues / `33` open PRs and `82` total issues / `47` total PRs, with all `33` open PR refs still mirrored into the fork. +- The latest open-only snapshot refreshed again on March 11, 2026 at `2026-03-11T11:21:38.789135+00:00`, and the latest full-state snapshot refreshed successfully at `2026-03-11T11:21:55.191917+00:00`; the local machine-readable intake now shows `36` open issues / `33` open PRs and `84` total issues / `47` total PRs after upstream added issues `#139` and `#140`. +- The latest full-state snapshot refreshed again on March 11, 2026 at `2026-03-11T11:32:11.775883+00:00`; the local machine-readable intake remains current at `36` open issues / `33` open PRs and `84` total issues / `47` total PRs for the current evolve pass. +- The latest open-only snapshot refreshed again on March 11, 2026 at `2026-03-11T11:35:38.882213+00:00`, and the latest full-state snapshot refreshed again at `2026-03-11T11:35:54.437491+00:00`; intake remains current at `36` open issues / `33` open PRs and `84` total issues / `47` total PRs, with all `33` open PR refs still mirrored into the fork. +- The latest open-only snapshot refreshed again on March 11, 2026 at `2026-03-11T11:39:25.726371+00:00`, and the latest full-state snapshot refreshed again at `2026-03-11T11:39:50.317132+00:00`; intake remains current at `36` open issues / `33` open PRs and `84` total issues / `47` total PRs, with all open PR refs still mirrored into the fork. +- The latest open-only snapshot refreshed again on March 11, 2026 at `2026-03-11T11:50:44.955760+00:00`, and the latest full-state snapshot refreshed again at `2026-03-11T11:50:59.420568+00:00`; intake remains current at `36` open issues / `33` open PRs and `84` total issues / `47` total PRs, and this evolve pass shifted from PR-queue review to tightening the direct OpenAI-compatible runner path. +- The latest open-only snapshot refreshed again on March 11, 2026 at `2026-03-11T13:48:28.268154+00:00`, and the latest full-state snapshot refreshed again immediately afterward; intake is current at `35` open issues / `34` open PRs and `84` total issues / `48` total PRs, with all `34` open PR refs mirrored into the fork. +- The latest open-only snapshot refreshed again on March 11, 2026 at `2026-03-11T14:26:10.244914+00:00`, and the latest full-state snapshot refreshed successfully at `2026-03-11T14:28:11.329900+00:00`; intake is current at `36` open issues / `34` open PRs and `85` total issues / `48` total PRs, with all `34` open PR refs and all `48` total PR refs mirrored into the fork. +- The latest open-only snapshot refreshed again on March 11, 2026 at `2026-03-11T16:52:30.198382+00:00`, and the latest full-state snapshot refreshed successfully at `2026-03-11T16:52:55.300915+00:00`; intake is current at `39` open issues / `37` open PRs and `88` total issues / `51` total PRs, with all `37` open PR refs and all `51` total PR refs mirrored into the fork after upstream added issue `#148` plus more historical items. +- The latest open-only snapshot refreshed again on March 11, 2026 at `2026-03-11T20:34:17.363630+00:00`, and the latest full-state snapshot refreshed successfully at `2026-03-11T20:34:03.058410+00:00`; intake is current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all `39` open PR refs, all `53` total PR refs, and all `91` issue mirrors now current in the fork. +- After that refresh, the remaining open upstream queue still contains no new safe cherry-picks: every open PR is either already landed/covered locally, intentionally tracked for repo-native reimplementation, or otherwise unsafe to merge blindly, and the only still-open unimplemented issue without a direct fix path is the `#56` reference/discussion thread about localized Zep alternatives. +- Upstream issue `#148` is now covered locally: interview env liveness validates the persisted run-state status and recorded process PID before trusting `env_status.json`, which turns the stale-environment world-agent chat path into the existing fast closed-environment guidance instead of a delayed `504`. `cd backend && uv run pytest -q tests/test_simulation_runner_actions.py tests/test_simulation_api_i18n.py` and `bash ./scripts/test_backend_lite.sh` both pass with the new regression coverage. +- `.env.example` is now bilingual for the direct OpenAI-compatible setup path, so the shipped environment template matches the already-documented support for `OPENAI_*` aliases, Codex-compatible gateways, DashScope Coding Plan, and the no-`LLM_PROVIDER` configuration flow from both READMEs. +- Those JSON snapshots now also retain compact body/comment previews, which made issue `#64` immediately more actionable by exposing the server-deployment and model/Zep configuration clues from the latest discussion without another live GitHub round-trip. +- `scripts/sync_upstream_github.py` now supports paginated `--state all` refreshes, hydrates PR detail records so machine-readable snapshots include labels plus `mergeable_state`, preserves upstream head/base repo identity and head SHA for deterministic mirroring, annotates optional fork mirror status via `--fork-remote`, and uses `GITHUB_TOKEN` / `GH_TOKEN` when available; when those env vars are absent but the GitHub CLI is authenticated, it falls back to `gh api`, retries transient CLI/API failures, and then degrades to direct HTTP fetches so upstream intake is less brittle under rate-limit or transport hiccup conditions. +- When direct refresh is blocked by GitHub rate limiting, the sync script now treats a recent matching local snapshot as a valid cached intake source instead of failing hard, which keeps beads/evolve passes moving while preserving machine-readable upstream state on disk. +- The sync script again accepts the legacy `--json-out` / `--md-out` flag names as aliases for `--output` / `--summary`, which keeps older evolve notes and wrapper commands working while the newer CLI names remain primary. +- The sync script now also accepts `--timeout` (or `MIROFISH_GITHUB_SYNC_TIMEOUT`) so evolve passes can bound each GitHub request instead of risking a stuck upstream-ingest cycle. +- The sync script now also accepts `--max-workers` (or `MIROFISH_GITHUB_SYNC_MAX_WORKERS`) so full-history syncs can bound total wall-clock time by hydrating per-item GitHub detail/comment requests concurrently instead of serially. +- The latest open-only snapshot refreshed again on March 11, 2026 at `2026-03-11T23:35:03.722605+00:00`, and the latest full-state snapshot refreshed again at `2026-03-11T23:35:13.535197+00:00`; intake remains current at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all mirrored issue and PR visibility artifacts still current in the fork. +- That pass re-verified the repo-native direct OpenAI/Codex-compatible backend path through the current config diagnostics and validation bundle: `README*`, `.env.example`, `backend/scripts/print_config_status.py`, the frontend diagnostics model, and `bash ./scripts/test_backend_lite.sh` plus `npm --prefix frontend test -- --runInBand frontend/tests/apiConfigDiagnostics.test.mjs` all remain aligned on `LLM_*` and `OPENAI_*` alias support, so no extra raw-provider-only setup work is currently required. +- No new safe upstream PR emerged in the refreshed queue; every still-open PR remains either already landed locally, mirrored only for visibility, or intentionally deferred to a repo-native follow-up such as `mirofish-8eg`, `mirofish-3j8`, or `mirofish-hj9`. +- The latest forced refresh completed on March 12, 2026 at `2026-03-12T00:02:03.454933+00:00` for the open queue and immediately afterward for the full snapshot; intake still sits at `41` open issues / `39` open PRs and `91` total issues / `53` total PRs, with all `39` open PR refs and all `41` open issue mirrors remaining current in the fork. +- That March 12 pass re-checked the remaining clean open PRs and still found no safe cherry-pick beyond work already landed locally. PR `#119` remains a broad older-tree localization branch rather than a mergeable incremental remainder, so the queue stays limited to already-landed, partial, tracked, or unsafe items. +- The latest refresh completed again on March 12, 2026 at `2026-03-12T01:32:38.539214+00:00` for the open queue and `2026-03-12T01:32:49.112619+00:00` for the full snapshot; open intake remains `42` open issues / `40` open PRs while the historical mirror now covers `92` total issues / `54` total PRs, with all `40` open PR refs and all `42` open issue mirrors current in the fork. +- That latest March 12 pass re-ran the safe-PR review and again found no new low-risk merge or cherry-pick target: every still-open PR is either already landed locally, only partially absorbed (`#119`, `#147`), tracked for repo-native follow-up (`#144`, `#155`), or unsafe to merge wholesale (`#141`, `#118`, `#108`, `#70`, `#38`, `#49`). +- `env -i PATH="$PATH" HOME="$HOME" OPENAI_API_KEY=test-key OPENAI_API_BASE_URL=https://codex.example.test/v1 OPENAI_MODEL=gpt-4.1-mini npm run check:backend-config -- --compact` still reports `llm.backend_mode = openai_compatible`, which reconfirms direct Codex/OpenAI-compatible backend wiring. The command exits nonzero only because `ZEP_API_KEY is not configured`, so the remaining blocker is still Step 1 graph build rather than the direct LLM/backend path. + +## Practical mirror strategy for the fork + +- Mirror the highest-signal upstream PR branches to the fork when they are under active review. +- Fork visibility now includes the full upstream PR history mirrored as `origin/mirror/upstream-pr-*`: all `40` currently open upstream PR refs and all `54` total upstream PR refs captured in the latest full snapshot. +- Keep detailed execution tracking in local beads issues to avoid spamming the fork with every upstream item. +- Use `scripts/sync_upstream_github.py --fork-remote origin` to refresh a machine-readable snapshot and a concise markdown summary before each new evolve pass. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8c4fa710..e29dbb91 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "axios": "^1.13.2", "d3": "^7.9.0", "vue": "^3.5.24", + "vue-i18n": "^9.14.5", "vue-router": "^4.6.3" }, "devDependencies": { @@ -506,6 +507,50 @@ "node": ">=18" } }, + "node_modules/@intlify/core-base": { + "version": "9.14.5", + "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.14.5.tgz", + "integrity": "sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA==", + "license": "MIT", + "dependencies": { + "@intlify/message-compiler": "9.14.5", + "@intlify/shared": "9.14.5" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/message-compiler": { + "version": "9.14.5", + "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.14.5.tgz", + "integrity": "sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ==", + "license": "MIT", + "dependencies": { + "@intlify/shared": "9.14.5", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/shared": { + "version": "9.14.5", + "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.14.5.tgz", + "integrity": "sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -1331,7 +1376,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -1809,7 +1853,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1943,7 +1986,6 @@ "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -2018,7 +2060,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz", "integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.25", "@vue/compiler-sfc": "3.5.25", @@ -2035,6 +2076,27 @@ } } }, + "node_modules/vue-i18n": { + "version": "9.14.5", + "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.14.5.tgz", + "integrity": "sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==", + "deprecated": "v9 and v10 no longer supported. please migrate to v11. about maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html", + "license": "MIT", + "dependencies": { + "@intlify/core-base": "9.14.5", + "@intlify/shared": "9.14.5", + "@vue/devtools-api": "^6.5.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, "node_modules/vue-router": { "version": "4.6.3", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index f7e995a1..bc5916e9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,12 +6,14 @@ "scripts": { "dev": "vite --host", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "node --test tests/*.test.mjs" }, "dependencies": { "axios": "^1.13.2", "d3": "^7.9.0", "vue": "^3.5.24", + "vue-i18n": "^9.14.5", "vue-router": "^4.6.3" }, "devDependencies": { diff --git a/frontend/src/App.vue b/frontend/src/App.vue index b7cd71ca..5552e230 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -3,7 +3,13 @@ </template> <script setup> -// 使用 Vue Router 来管理页面 +import { onMounted } from 'vue' +import { getStoredLocale } from './i18n' + +onMounted(() => { + const locale = getStoredLocale() + document.documentElement.lang = locale === 'en' ? 'en' : 'zh-CN' +}) </script> <style> diff --git a/frontend/src/api/baseUrl.js b/frontend/src/api/baseUrl.js new file mode 100644 index 00000000..2018e9c2 --- /dev/null +++ b/frontend/src/api/baseUrl.js @@ -0,0 +1,82 @@ +export const API_BASE_OVERRIDE_KEY = 'mirofish-api-base-url' + +export const normalizeBaseURL = (value) => { + const trimmedValue = value?.trim() + if (!trimmedValue) { + return '' + } + + return trimmedValue.replace(/\/+$/, '') +} + +export const getStoredBaseURL = (storage = globalThis?.localStorage) => { + if (!storage) { + return '' + } + + try { + return normalizeBaseURL(storage.getItem(API_BASE_OVERRIDE_KEY)) + } catch { + return '' + } +} + +export const setStoredBaseURL = (value, storage = globalThis?.localStorage) => { + if (!storage) { + return '' + } + + const normalizedValue = normalizeBaseURL(value) + try { + if (normalizedValue) { + storage.setItem(API_BASE_OVERRIDE_KEY, normalizedValue) + } else { + storage.removeItem(API_BASE_OVERRIDE_KEY) + } + } catch { + return '' + } + + return normalizedValue +} + +export const clearStoredBaseURL = (storage = globalThis?.localStorage) => { + if (!storage) { + return + } + + try { + storage.removeItem(API_BASE_OVERRIDE_KEY) + } catch { + // Ignore storage failures and fall back to auto-detection. + } +} + +export const resolveBaseURL = ({ runtimeBaseURL, envBaseURL, location } = {}) => { + const trimmedRuntimeBaseURL = normalizeBaseURL(runtimeBaseURL) + if (trimmedRuntimeBaseURL) { + return trimmedRuntimeBaseURL + } + + const trimmedEnvBaseURL = normalizeBaseURL(envBaseURL) + if (trimmedEnvBaseURL) { + return trimmedEnvBaseURL + } + + if (!location) { + return '' + } + + const origin = location.origin || `${location.protocol}//${location.host}` + if (!origin) { + return '' + } + + if (location.port === '3000') { + const backendOrigin = new URL(origin) + backendOrigin.port = '5001' + return backendOrigin.origin + } + + return origin +} diff --git a/frontend/src/api/errors.js b/frontend/src/api/errors.js new file mode 100644 index 00000000..88226e9a --- /dev/null +++ b/frontend/src/api/errors.js @@ -0,0 +1,90 @@ +const MISSING_KEY_RE = /^([A-Z0-9_ /]+)\s*未配置$/ +const CONFIG_INCOMPLETE_RE = /^后端配置不完整:\s*(.+)$/ + +const localizeBackendConfigMessage = (message, t) => { + if (typeof message !== 'string' || !t) { + return message + } + + const normalized = message.trim() + if (!normalized) { + return message + } + + const directMissingMatch = normalized.match(MISSING_KEY_RE) + if (directMissingMatch) { + return t('process.missingConfigKey', { name: directMissingMatch[1].trim() }) + } + + const configMatch = normalized.match(CONFIG_INCOMPLETE_RE) + if (configMatch) { + const detail = configMatch[1].trim() + const translatedDetail = localizeBackendConfigMessage(detail, t) + return t('process.backendConfigIncomplete', { details: translatedDetail }) + } + + return message +} + +const getCapabilityHint = (payload, t) => { + if (!payload || !t) { + return '' + } + + const capabilities = payload?.data?.summary?.capabilities || payload?.summary?.capabilities + if (!capabilities || typeof capabilities !== 'object') { + return '' + } + + const directLlmReady = Boolean(capabilities.direct_llm?.ready) + const zepBlocked = [capabilities.graph_build, capabilities.graph_report_tools].some((capability) => + capability && capability.ready === false && capability.requires_zep, + ) + + if (directLlmReady && zepBlocked) { + return t('apiConfig.diagnostics.zepMissingNote') + } + + return '' +} + +const appendHint = (message, hint) => { + if (!hint) { + return message + } + if (!message) { + return hint + } + if (message.includes(hint)) { + return message + } + return `${message} ${hint}` +} + +export const formatApiError = ({ + err, + t, + resolveBaseURL = () => '', + locationOrigin = '', +}) => { + if (!err) return t('process.unknownError') + + if (err.code === 'ECONNABORTED' || String(err.message || '').includes('timeout')) { + return t('process.requestTimeout') + } + + if (err.message === 'Network Error') { + const apiBase = resolveBaseURL() || locationOrigin + return t('process.backendUnavailable', { apiBase }) + } + + const backendMessage = err.response?.data?.error || err.response?.data?.message + if (backendMessage) { + return appendHint( + localizeBackendConfigMessage(backendMessage, t), + getCapabilityHint(err.response?.data, t), + ) + } + + return err.message || t('process.unknownError') +} diff --git a/frontend/src/api/graph.js b/frontend/src/api/graph.js index ef90a2b6..02d97255 100644 --- a/frontend/src/api/graph.js +++ b/frontend/src/api/graph.js @@ -68,3 +68,14 @@ export function getProject(projectId) { method: 'get' }) } + +/** + * 获取后端配置状态,便于在前端快速诊断缺失的服务端配置 + * @returns {Promise} + */ +export function getBackendConfigStatus() { + return service({ + url: '/api/graph/config/status', + method: 'get' + }) +} diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index e2d9465b..bd2e3e54 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -1,9 +1,40 @@ import axios from 'axios' +import { getStoredBaseURL, resolveBaseURL as resolveApiBaseURL } from './baseUrl' +import { resolveTimeoutMs } from './timeout' + +const createApiError = (message, extras = {}) => { + const error = new Error(message) + Object.assign(error, extras) + return error +} + +export const resolveBaseURL = () => { + return resolveApiBaseURL({ + runtimeBaseURL: getStoredBaseURL(), + envBaseURL: import.meta.env.VITE_API_BASE_URL, + location: typeof window !== 'undefined' ? window.location : undefined + }) +} + +const getApiLocale = () => { + if (typeof window === 'undefined') { + return null + } + + try { + const locale = window.localStorage.getItem('mirofish-locale') + return locale === 'en' || locale === 'zh' ? locale : null + } catch { + return null + } +} + +export const getConfiguredApiTimeoutMs = () => resolveTimeoutMs(import.meta.env.VITE_API_TIMEOUT) // 创建axios实例 const service = axios.create({ - baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:5001', - timeout: 300000, // 5分钟超时(本体生成可能需要较长时间) + baseURL: resolveBaseURL(), + timeout: getConfiguredApiTimeoutMs(), // 可配置超时时间,默认5分钟(本地大模型可能需要更长时间) headers: { 'Content-Type': 'application/json' } @@ -12,6 +43,11 @@ const service = axios.create({ // 请求拦截器 service.interceptors.request.use( config => { + config.baseURL = resolveBaseURL() + const locale = getApiLocale() + if (locale) { + config.headers['X-Locale'] = locale + } return config }, error => { @@ -28,7 +64,13 @@ service.interceptors.response.use( // 如果返回的状态码不是success,则抛出错误 if (!res.success && res.success !== undefined) { console.error('API Error:', res.error || res.message || 'Unknown error') - return Promise.reject(new Error(res.error || res.message || 'Error')) + return Promise.reject(createApiError(res.error || res.message || 'Error', { + code: 'API_ERROR', + response: { + ...response, + data: res + } + })) } return res diff --git a/frontend/src/api/simulation.js b/frontend/src/api/simulation.js index f878586f..67e34370 100644 --- a/frontend/src/api/simulation.js +++ b/frontend/src/api/simulation.js @@ -35,19 +35,21 @@ export const getSimulation = (simulationId) => { /** * 获取模拟的 Agent Profiles * @param {string} simulationId - * @param {string} platform - 'reddit' | 'twitter' + * @param {string} [platform] - 'reddit' | 'twitter'(省略时由后端根据模拟配置自动选择) */ -export const getSimulationProfiles = (simulationId, platform = 'reddit') => { - return service.get(`/api/simulation/${simulationId}/profiles`, { params: { platform } }) +export const getSimulationProfiles = (simulationId, platform) => { + const params = platform ? { platform } : {} + return service.get(`/api/simulation/${simulationId}/profiles`, { params }) } /** * 实时获取生成中的 Agent Profiles * @param {string} simulationId - * @param {string} platform - 'reddit' | 'twitter' + * @param {string} [platform] - 'reddit' | 'twitter'(省略时由后端根据模拟配置自动选择) */ -export const getSimulationProfilesRealtime = (simulationId, platform = 'reddit') => { - return service.get(`/api/simulation/${simulationId}/profiles/realtime`, { params: { platform } }) +export const getSimulationProfilesRealtime = (simulationId, platform) => { + const params = platform ? { platform } : {} + return service.get(`/api/simulation/${simulationId}/profiles/realtime`, { params }) } /** @@ -103,22 +105,23 @@ export const getRunStatus = (simulationId) => { /** * 获取模拟运行详细状态(包含最近动作) * @param {string} simulationId + * @param {Object} params - { platform?, since?, limit? } */ -export const getRunStatusDetail = (simulationId) => { - return service.get(`/api/simulation/${simulationId}/run-status/detail`) +export const getRunStatusDetail = (simulationId, params = {}) => { + return service.get(`/api/simulation/${simulationId}/run-status/detail`, { params }) } /** * 获取模拟中的帖子 * @param {string} simulationId - * @param {string} platform - 'reddit' | 'twitter' + * @param {string} [platform] - 'reddit' | 'twitter'(省略时由后端根据模拟配置自动选择) * @param {number} limit - 返回数量 * @param {number} offset - 偏移量 */ -export const getSimulationPosts = (simulationId, platform = 'reddit', limit = 50, offset = 0) => { - return service.get(`/api/simulation/${simulationId}/posts`, { - params: { platform, limit, offset } - }) +export const getSimulationPosts = (simulationId, platform, limit = 50, offset = 0) => { + const params = { limit, offset } + if (platform) params.platform = platform + return service.get(`/api/simulation/${simulationId}/posts`, { params }) } /** @@ -185,3 +188,10 @@ export const getSimulationHistory = (limit = 20) => { return service.get('/api/simulation/history', { params: { limit } }) } +/** + * 删除历史模拟记录及其本地关联资产 + * @param {string} simulationId + */ +export const deleteSimulationHistory = (simulationId) => { + return service.delete(`/api/simulation/history/${simulationId}`) +} diff --git a/frontend/src/api/timeout.js b/frontend/src/api/timeout.js new file mode 100644 index 00000000..abd07b57 --- /dev/null +++ b/frontend/src/api/timeout.js @@ -0,0 +1,23 @@ +export const resolveTimeoutMs = (rawTimeout, fallback = 300000) => { + const envTimeout = Number.parseInt(rawTimeout, 10) + return Number.isFinite(envTimeout) && envTimeout > 0 ? envTimeout : fallback +} + +export const deriveInterviewTimeoutSeconds = ({ + requestTimeoutMs = 300000, + interviewsCount = 1, + bufferSeconds = 5, + baseSeconds = 90, + perInterviewSeconds = 20, +}) => { + const boundedCount = Number.isFinite(interviewsCount) && interviewsCount > 0 + ? Math.floor(interviewsCount) + : 1 + const requestBudgetSeconds = Math.max( + 30, + Math.floor(resolveTimeoutMs(requestTimeoutMs) / 1000) - bufferSeconds + ) + const requestedSeconds = baseSeconds + Math.max(0, boundedCount - 1) * perInterviewSeconds + + return Math.max(30, Math.min(requestBudgetSeconds, requestedSeconds)) +} diff --git a/frontend/src/components/ApiEndpointControl.vue b/frontend/src/components/ApiEndpointControl.vue new file mode 100644 index 00000000..deb8e772 --- /dev/null +++ b/frontend/src/components/ApiEndpointControl.vue @@ -0,0 +1,416 @@ +<template> + <div class="api-endpoint-control" :class="{ compact }"> + <button + class="toggle-btn" + type="button" + @click="expanded = !expanded" + :aria-expanded="expanded ? 'true' : 'false'" + > + <span>{{ t('apiConfig.trigger') }}</span> + <span class="toggle-value">{{ activeBaseURL || t('apiConfig.autoMode') }}</span> + </button> + + <div v-if="expanded" class="panel"> + <div class="panel-header"> + <span class="panel-title">{{ t('apiConfig.title') }}</span> + <span class="panel-mode">{{ overrideBaseURL ? t('apiConfig.customMode') : t('apiConfig.autoMode') }}</span> + </div> + + <p class="panel-copy">{{ t('apiConfig.description') }}</p> + + <div class="diagnostics"> + <div class="diagnostics-header"> + <span class="diagnostics-title">{{ t('apiConfig.diagnostics.title') }}</span> + <button class="diagnostics-refresh" type="button" @click="loadBackendDiagnostics(true)"> + {{ t('apiConfig.diagnostics.refresh') }} + </button> + </div> + <p class="diagnostics-copy">{{ t('apiConfig.diagnostics.description') }}</p> + <p v-if="backendLoading" class="diagnostics-state">{{ t('apiConfig.diagnostics.loading') }}</p> + <p v-else-if="backendError" class="diagnostics-state diagnostics-state--error">{{ backendError }}</p> + <div v-else-if="backendDiagnostic" class="diagnostics-card"> + <span class="diagnostics-badge" :class="`diagnostics-badge--${backendDiagnostic.tone}`"> + {{ backendDiagnostic.headline }} + </span> + <p v-if="backendDiagnostic.note" class="diagnostics-state diagnostics-state--warning"> + {{ backendDiagnostic.note }} + </p> + <div v-if="backendDiagnostic.nextSteps?.length" class="diagnostics-next-steps"> + <p class="diagnostics-next-steps-title">{{ t('apiConfig.diagnostics.nextStepsTitle') }}</p> + <ul class="diagnostics-next-steps-list"> + <li + v-for="step in backendDiagnostic.nextSteps" + :key="step" + class="diagnostics-next-step" + > + {{ step }} + </li> + </ul> + </div> + <div class="diagnostics-grid"> + <div v-for="row in backendDiagnostic.rows" :key="row.label" class="diagnostics-row"> + <span class="diagnostics-label">{{ row.label }}</span> + <span class="diagnostics-value">{{ row.value }}</span> + </div> + </div> + </div> + </div> + + <label class="input-label" for="api-base-url-input">{{ t('apiConfig.inputLabel') }}</label> + <input + id="api-base-url-input" + v-model="draftBaseURL" + class="endpoint-input" + type="text" + :placeholder="t('apiConfig.placeholder')" + autocapitalize="off" + autocomplete="off" + spellcheck="false" + /> + + <div class="actions"> + <button class="save-btn" type="button" @click="save">{{ t('apiConfig.save') }}</button> + <button class="reset-btn" type="button" @click="reset">{{ t('apiConfig.reset') }}</button> + </div> + + <p class="current-endpoint"> + {{ t('apiConfig.current') }} <span>{{ activeBaseURL || t('apiConfig.autoMode') }}</span> + </p> + <p v-if="message" class="message">{{ message }}</p> + </div> + </div> +</template> + +<script setup> +import { computed, ref, watch } from 'vue' +import { useI18n } from 'vue-i18n' +import { formatApiError } from '../api/errors' +import { getBackendConfigStatus } from '../api/graph' +import { + clearStoredBaseURL, + getStoredBaseURL, + normalizeBaseURL, + resolveBaseURL, + setStoredBaseURL, +} from '../api/baseUrl' +import { buildBackendDiagnosticModel } from './apiConfigDiagnostics' + +const props = defineProps({ + compact: { + type: Boolean, + default: false, + }, +}) + +const { t } = useI18n() +const expanded = ref(false) +const message = ref('') +const overrideBaseURL = ref(getStoredBaseURL()) +const draftBaseURL = ref(overrideBaseURL.value) +const backendLoading = ref(false) +const backendError = ref('') +const backendDiagnostic = ref(null) +const backendLoaded = ref(false) + +const activeBaseURL = computed(() => { + return resolveBaseURL({ + runtimeBaseURL: overrideBaseURL.value, + envBaseURL: import.meta.env.VITE_API_BASE_URL, + location: typeof window !== 'undefined' ? window.location : undefined, + }) +}) + +const loadBackendDiagnostics = async (forceRefresh = false) => { + if (backendLoading.value || (backendLoaded.value && !forceRefresh)) { + return + } + + backendLoading.value = true + backendError.value = '' + + try { + const response = await getBackendConfigStatus() + backendDiagnostic.value = buildBackendDiagnosticModel(response.data, t) + backendLoaded.value = true + } catch (err) { + backendError.value = formatApiError({ + err, + t, + resolveBaseURL, + locationOrigin: typeof window !== 'undefined' ? window.location.origin : '', + }) + } finally { + backendLoading.value = false + } +} + +watch(expanded, (isExpanded) => { + if (isExpanded) { + loadBackendDiagnostics() + } +}) + +const save = () => { + const normalizedValue = normalizeBaseURL(draftBaseURL.value) + if (normalizedValue && !/^https?:\/\//i.test(normalizedValue)) { + message.value = t('apiConfig.invalid') + return + } + + const savedValue = setStoredBaseURL(normalizedValue) + overrideBaseURL.value = savedValue + draftBaseURL.value = savedValue + message.value = savedValue ? t('apiConfig.saved') : t('apiConfig.autoSaved') +} + +const reset = () => { + clearStoredBaseURL() + overrideBaseURL.value = '' + draftBaseURL.value = '' + message.value = t('apiConfig.resetDone') +} +</script> + +<style scoped> +.api-endpoint-control { + position: relative; +} + +.toggle-btn { + display: inline-flex; + align-items: center; + gap: 10px; + max-width: 100%; + padding: 10px 14px; + border: 1px solid #000; + background: #fff; + color: #000; + font-size: 12px; + cursor: pointer; +} + +.toggle-value { + max-width: 280px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #666; +} + +.compact .toggle-btn { + padding: 8px 12px; +} + +.panel { + position: absolute; + top: calc(100% + 10px); + right: 0; + z-index: 30; + width: min(360px, 90vw); + padding: 16px; + border: 1px solid #000; + background: #fff; + box-shadow: 8px 8px 0 #000; +} + +.panel-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; +} + +.panel-title { + font-size: 13px; + font-weight: 700; +} + +.panel-mode { + font-size: 11px; + color: #666; +} + +.panel-copy, +.current-endpoint, +.message, +.input-label, +.diagnostics-copy, +.diagnostics-state, +.diagnostics-next-steps-title, +.diagnostics-next-step, +.diagnostics-label, +.diagnostics-value { + font-size: 12px; + line-height: 1.5; +} + +.diagnostics { + margin: 14px 0; + padding: 12px; + border: 1px solid #000; + background: #fafafa; +} + +.diagnostics-next-steps { + margin: 10px 0 12px; + padding: 10px; + border: 1px dashed #000; + background: #fff; +} + +.diagnostics-next-steps-title { + margin: 0 0 6px; + font-weight: 700; +} + +.diagnostics-next-steps-list { + margin: 0; + padding-left: 18px; +} + +.diagnostics-next-step { + margin: 0; +} + +.diagnostics-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.diagnostics-title { + font-size: 12px; + font-weight: 700; +} + +.diagnostics-refresh { + padding: 4px 8px; + border: 1px solid #000; + background: #fff; + font-size: 11px; + cursor: pointer; +} + +.diagnostics-copy, +.diagnostics-state { + margin-top: 8px; +} + +.diagnostics-state--error { + color: #a40000; +} + +.diagnostics-state--warning { + color: #7a4b00; +} + +.diagnostics-card { + margin-top: 8px; +} + +.diagnostics-badge { + display: inline-flex; + padding: 4px 8px; + border: 1px solid #000; + font-size: 11px; + font-weight: 700; +} + +.diagnostics-badge--ready { + background: #e6ffed; +} + +.diagnostics-badge--warning { + background: #fff4d6; +} + +.diagnostics-grid { + display: grid; + gap: 8px; + margin-top: 10px; +} + +.diagnostics-row { + display: grid; + gap: 2px; +} + +.diagnostics-label { + color: #666; +} + +.diagnostics-value { + word-break: break-word; +} + +.input-label { + display: block; + margin: 12px 0 6px; + font-weight: 700; +} + +.endpoint-input { + width: 100%; + padding: 10px 12px; + border: 1px solid #000; + font: inherit; +} + +.actions { + display: flex; + gap: 10px; + margin-top: 12px; +} + +.save-btn, +.reset-btn { + flex: 1; + padding: 10px 12px; + border: 1px solid #000; + font: inherit; + cursor: pointer; +} + +.save-btn { + background: #000; + color: #fff; +} + +.reset-btn { + background: #fff; + color: #000; +} + +.current-endpoint { + margin-top: 12px; +} + +.current-endpoint span, +.message { + word-break: break-all; +} + +.message { + margin-top: 8px; + color: #666; +} + +@media (max-width: 720px) { + .panel { + left: 0; + right: auto; + width: min(360px, calc(100vw - 32px)); + } + + .toggle-btn { + width: 100%; + justify-content: space-between; + } + + .toggle-value { + max-width: 160px; + } +} +</style> diff --git a/frontend/src/components/GraphPanel.vue b/frontend/src/components/GraphPanel.vue index 314c966e..ff438c11 100644 --- a/frontend/src/components/GraphPanel.vue +++ b/frontend/src/components/GraphPanel.vue @@ -1,14 +1,14 @@ <template> <div class="graph-panel"> <div class="panel-header"> - <span class="panel-title">Graph Relationship Visualization</span> + <span class="panel-title">{{ t('graphPanel.title') }}</span> <!-- 顶部工具栏 (Internal Top Right) --> <div class="header-tools"> - <button class="tool-btn" @click="$emit('refresh')" :disabled="loading" title="刷新图谱"> + <button class="tool-btn" @click="$emit('refresh')" :disabled="loading" :title="t('graphPanel.refresh')"> <span class="icon-refresh" :class="{ 'spinning': loading }">↻</span> - <span class="btn-text">Refresh</span> + <span class="btn-text">{{ t('graphPanel.refresh') }}</span> </button> - <button class="tool-btn" @click="$emit('toggle-maximize')" title="最大化/还原"> + <button class="tool-btn" @click="$emit('toggle-maximize')" :title="t('graphPanel.toggleMaximize')"> <span class="icon-maximize">⛶</span> </button> </div> @@ -27,7 +27,7 @@ <path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-4.44-4.04z" /> </svg> </div> - {{ isSimulating ? 'GraphRAG长短期记忆实时更新中' : '实时更新中...' }} + {{ isSimulating ? t('graphPanel.simulationUpdating') : t('graphPanel.liveUpdating') }} </div> <!-- 模拟结束后的提示 --> @@ -39,8 +39,8 @@ <line x1="12" y1="8" x2="12.01" y2="8"></line> </svg> </div> - <span class="hint-text">还有少量内容处理中,建议稍后手动刷新图谱</span> - <button class="hint-close-btn" @click="dismissFinishedHint" title="关闭提示"> + <span class="hint-text">{{ t('graphPanel.finishedHint') }}</span> + <button class="hint-close-btn" @click="dismissFinishedHint" :title="t('graphPanel.dismissHint')"> <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"> <line x1="18" y1="6" x2="6" y2="18"></line> <line x1="6" y1="6" x2="18" y2="18"></line> @@ -51,7 +51,7 @@ <!-- 节点/边详情面板 --> <div v-if="selectedItem" class="detail-panel"> <div class="detail-panel-header"> - <span class="detail-title">{{ selectedItem.type === 'node' ? 'Node Details' : 'Relationship' }}</span> + <span class="detail-title">{{ selectedItem.type === 'node' ? t('graphPanel.nodeDetails') : t('graphPanel.relationship') }}</span> <span v-if="selectedItem.type === 'node'" class="detail-type-badge" :style="{ background: selectedItem.color, color: '#fff' }"> {{ selectedItem.entityType }} </span> @@ -61,38 +61,47 @@ <!-- 节点详情 --> <div v-if="selectedItem.type === 'node'" class="detail-content"> <div class="detail-row"> - <span class="detail-label">Name:</span> + <span class="detail-label">{{ t('graphPanel.name') }}:</span> <span class="detail-value">{{ selectedItem.data.name }}</span> </div> <div class="detail-row"> - <span class="detail-label">UUID:</span> + <span class="detail-label">{{ t('graphPanel.uuid') }}:</span> <span class="detail-value uuid-text">{{ selectedItem.data.uuid }}</span> </div> <div class="detail-row" v-if="selectedItem.data.created_at"> - <span class="detail-label">Created:</span> + <span class="detail-label">{{ t('graphPanel.created') }}:</span> <span class="detail-value">{{ formatDateTime(selectedItem.data.created_at) }}</span> </div> + + <div class="detail-section" v-if="selectedNodeAliasNames.length"> + <div class="section-title">{{ t('graphPanel.aliases') }}:</div> + <div class="labels-list"> + <span v-for="alias in selectedNodeAliasNames" :key="alias" class="label-tag"> + {{ alias }} + </span> + </div> + </div> <!-- Properties --> <div class="detail-section" v-if="selectedItem.data.attributes && Object.keys(selectedItem.data.attributes).length > 0"> - <div class="section-title">Properties:</div> + <div class="section-title">{{ t('graphPanel.properties') }}:</div> <div class="properties-list"> <div v-for="(value, key) in selectedItem.data.attributes" :key="key" class="property-item"> <span class="property-key">{{ key }}:</span> - <span class="property-value">{{ value || 'None' }}</span> + <span class="property-value">{{ value || t('common.none') }}</span> </div> </div> </div> <!-- Summary --> <div class="detail-section" v-if="selectedItem.data.summary"> - <div class="section-title">Summary:</div> + <div class="section-title">{{ t('graphPanel.summary') }}:</div> <div class="summary-text">{{ selectedItem.data.summary }}</div> </div> <!-- Labels --> <div class="detail-section" v-if="selectedItem.data.labels && selectedItem.data.labels.length > 0"> - <div class="section-title">Labels:</div> + <div class="section-title">{{ t('graphPanel.labels') }}:</div> <div class="labels-list"> <span v-for="label in selectedItem.data.labels" :key="label" class="label-tag"> {{ label }} @@ -106,8 +115,8 @@ <!-- 自环组详情 --> <template v-if="selectedItem.data.isSelfLoopGroup"> <div class="edge-relation-header self-loop-header"> - {{ selectedItem.data.source_name }} - Self Relations - <span class="self-loop-count">{{ selectedItem.data.selfLoopCount }} items</span> + {{ selectedItem.data.source_name }} - {{ t('graphPanel.selfRelations') }} + <span class="self-loop-count">{{ t('graphPanel.itemsCount', { count: selectedItem.data.selfLoopCount }) }}</span> </div> <div class="self-loop-list"> @@ -122,29 +131,29 @@ @click="toggleSelfLoop(loop.uuid || idx)" > <span class="self-loop-index">#{{ idx + 1 }}</span> - <span class="self-loop-name">{{ loop.name || loop.fact_type || 'RELATED' }}</span> + <span class="self-loop-name">{{ loop.name || loop.fact_type || t('graphPanel.related') }}</span> <span class="self-loop-toggle">{{ expandedSelfLoops.has(loop.uuid || idx) ? '−' : '+' }}</span> </div> <div class="self-loop-item-content" v-show="expandedSelfLoops.has(loop.uuid || idx)"> <div class="detail-row" v-if="loop.uuid"> - <span class="detail-label">UUID:</span> + <span class="detail-label">{{ t('graphPanel.uuid') }}:</span> <span class="detail-value uuid-text">{{ loop.uuid }}</span> </div> <div class="detail-row" v-if="loop.fact"> - <span class="detail-label">Fact:</span> + <span class="detail-label">{{ t('graphPanel.fact') }}:</span> <span class="detail-value fact-text">{{ loop.fact }}</span> </div> <div class="detail-row" v-if="loop.fact_type"> - <span class="detail-label">Type:</span> + <span class="detail-label">{{ t('graphPanel.type') }}:</span> <span class="detail-value">{{ loop.fact_type }}</span> </div> <div class="detail-row" v-if="loop.created_at"> - <span class="detail-label">Created:</span> + <span class="detail-label">{{ t('graphPanel.created') }}:</span> <span class="detail-value">{{ formatDateTime(loop.created_at) }}</span> </div> <div v-if="loop.episodes && loop.episodes.length > 0" class="self-loop-episodes"> - <span class="detail-label">Episodes:</span> + <span class="detail-label">{{ t('graphPanel.episodes') }}:</span> <div class="episodes-list compact"> <span v-for="ep in loop.episodes" :key="ep" class="episode-tag small">{{ ep }}</span> </div> @@ -157,29 +166,29 @@ <!-- 普通边详情 --> <template v-else> <div class="edge-relation-header"> - {{ selectedItem.data.source_name }} → {{ selectedItem.data.name || 'RELATED_TO' }} → {{ selectedItem.data.target_name }} + {{ selectedItem.data.source_name }} → {{ selectedItem.data.name || t('graphPanel.relatedTo') }} → {{ selectedItem.data.target_name }} </div> <div class="detail-row"> - <span class="detail-label">UUID:</span> + <span class="detail-label">{{ t('graphPanel.uuid') }}:</span> <span class="detail-value uuid-text">{{ selectedItem.data.uuid }}</span> </div> <div class="detail-row"> - <span class="detail-label">Label:</span> - <span class="detail-value">{{ selectedItem.data.name || 'RELATED_TO' }}</span> + <span class="detail-label">{{ t('graphPanel.label') }}:</span> + <span class="detail-value">{{ selectedItem.data.name || t('graphPanel.relatedTo') }}</span> </div> <div class="detail-row"> - <span class="detail-label">Type:</span> - <span class="detail-value">{{ selectedItem.data.fact_type || 'Unknown' }}</span> + <span class="detail-label">{{ t('graphPanel.type') }}:</span> + <span class="detail-value">{{ selectedItem.data.fact_type || t('graphPanel.unknown') }}</span> </div> <div class="detail-row" v-if="selectedItem.data.fact"> - <span class="detail-label">Fact:</span> + <span class="detail-label">{{ t('graphPanel.fact') }}:</span> <span class="detail-value fact-text">{{ selectedItem.data.fact }}</span> </div> <!-- Episodes --> <div class="detail-section" v-if="selectedItem.data.episodes && selectedItem.data.episodes.length > 0"> - <div class="section-title">Episodes:</div> + <div class="section-title">{{ t('graphPanel.episodes') }}:</div> <div class="episodes-list"> <span v-for="ep in selectedItem.data.episodes" :key="ep" class="episode-tag"> {{ ep }} @@ -188,11 +197,11 @@ </div> <div class="detail-row" v-if="selectedItem.data.created_at"> - <span class="detail-label">Created:</span> + <span class="detail-label">{{ t('graphPanel.created') }}:</span> <span class="detail-value">{{ formatDateTime(selectedItem.data.created_at) }}</span> </div> <div class="detail-row" v-if="selectedItem.data.valid_at"> - <span class="detail-label">Valid From:</span> + <span class="detail-label">{{ t('graphPanel.validFrom') }}:</span> <span class="detail-value">{{ formatDateTime(selectedItem.data.valid_at) }}</span> </div> </template> @@ -203,19 +212,19 @@ <!-- 加载状态 --> <div v-else-if="loading" class="graph-state"> <div class="loading-spinner"></div> - <p>图谱数据加载中...</p> + <p>{{ t('graphPanel.loading') }}</p> </div> <!-- 等待/空状态 --> <div v-else class="graph-state"> <div class="empty-icon">❖</div> - <p class="empty-text">等待本体生成...</p> + <p class="empty-text">{{ t('graphPanel.waiting') }}</p> </div> </div> <!-- 底部图例 (Bottom Left) --> <div v-if="graphData && entityTypes.length" class="graph-legend"> - <span class="legend-title">Entity Types</span> + <span class="legend-title">{{ t('graphPanel.entityTypes') }}</span> <div class="legend-items"> <div class="legend-item" v-for="type in entityTypes" :key="type.name"> <span class="legend-dot" :style="{ background: type.color }"></span> @@ -230,14 +239,17 @@ <input type="checkbox" v-model="showEdgeLabels" /> <span class="slider"></span> </label> - <span class="toggle-label">Show Edge Labels</span> + <span class="toggle-label">{{ t('graphPanel.showEdgeLabels') }}</span> </div> </div> </template> <script setup> import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue' +import { useI18n } from 'vue-i18n' import * as d3 from 'd3' +import { getDisplayedAliasNames } from './graphAliasDetails.js' +import { normalizeGraphPanelData } from './graphPanelData.js' const props = defineProps({ graphData: Object, @@ -247,6 +259,7 @@ const props = defineProps({ }) const emit = defineEmits(['refresh', 'toggle-maximize']) +const { t } = useI18n() const graphContainer = ref(null) const graphSvg = ref(null) @@ -255,6 +268,11 @@ const showEdgeLabels = ref(true) // 默认显示边标签 const expandedSelfLoops = ref(new Set()) // 展开的自环项 const showSimulationFinishedHint = ref(false) // 模拟结束后的提示 const wasSimulating = ref(false) // 追踪之前是否在模拟中 +const selectedNodeAliasNames = computed(() => + selectedItem.value?.type === 'node' + ? getDisplayedAliasNames(selectedItem.value.data) + : [], +) // 关闭模拟结束提示 const dismissFinishedHint = () => { @@ -282,21 +300,13 @@ const toggleSelfLoop = (id) => { } // 计算实体类型用于图例 -const entityTypes = computed(() => { - if (!props.graphData?.nodes) return [] - const typeMap = {} - // 美观的颜色调色板 - const colors = ['#FF6B35', '#004E89', '#7B2D8E', '#1A936F', '#C5283D', '#E9724C', '#3498db', '#9b59b6', '#27ae60', '#f39c12'] - - props.graphData.nodes.forEach(node => { - const type = node.labels?.find(l => l !== 'Entity') || 'Entity' - if (!typeMap[type]) { - typeMap[type] = { name: type, count: 0, color: colors[Object.keys(typeMap).length % colors.length] } - } - typeMap[type].count++ - }) - return Object.values(typeMap) -}) +const normalizedGraphData = computed(() => normalizeGraphPanelData({ + graphData: props.graphData, + unnamedNodeLabel: 'Unnamed', + unknownNodeLabel: t('graphPanel.unknown'), +})) + +const entityTypes = computed(() => normalizedGraphData.value.entityTypes) // 格式化时间 const formatDateTime = (dateStr) => { @@ -344,20 +354,20 @@ const renderGraph = () => { svg.selectAll('*').remove() - const nodesData = props.graphData.nodes || [] - const edgesData = props.graphData.edges || [] + const nodesData = normalizedGraphData.value.nodes || [] + const edgesData = normalizedGraphData.value.edges || [] if (nodesData.length === 0) return // Prep data const nodeMap = {} - nodesData.forEach(n => nodeMap[n.uuid] = n) + nodesData.forEach(n => nodeMap[n.id] = n.rawData) const nodes = nodesData.map(n => ({ - id: n.uuid, + id: n.id, name: n.name || 'Unnamed', - type: n.labels?.find(l => l !== 'Entity') || 'Entity', - rawData: n + type: n.type || 'Entity', + rawData: n.rawData })) const nodeIds = new Set(nodes.map(n => n.id)) @@ -366,22 +376,22 @@ const renderGraph = () => { const edgePairCount = {} const selfLoopEdges = {} // 按节点分组的自环边 const tempEdges = edgesData - .filter(e => nodeIds.has(e.source_node_uuid) && nodeIds.has(e.target_node_uuid)) + .filter(e => nodeIds.has(e.source) && nodeIds.has(e.target)) // 统计每对节点之间的边数量,收集自环边 tempEdges.forEach(e => { - if (e.source_node_uuid === e.target_node_uuid) { + if (e.source === e.target) { // 自环 - 收集到数组中 - if (!selfLoopEdges[e.source_node_uuid]) { - selfLoopEdges[e.source_node_uuid] = [] + if (!selfLoopEdges[e.source]) { + selfLoopEdges[e.source] = [] } - selfLoopEdges[e.source_node_uuid].push({ - ...e, - source_name: nodeMap[e.source_node_uuid]?.name, - target_name: nodeMap[e.target_node_uuid]?.name + selfLoopEdges[e.source].push({ + ...e.rawData, + source_name: e.rawData.source_name || nodeMap[e.source]?.name, + target_name: e.rawData.target_name || nodeMap[e.target]?.name }) } else { - const pairKey = [e.source_node_uuid, e.target_node_uuid].sort().join('_') + const pairKey = [e.source, e.target].sort().join('_') edgePairCount[pairKey] = (edgePairCount[pairKey] || 0) + 1 } }) @@ -393,21 +403,21 @@ const renderGraph = () => { const edges = [] tempEdges.forEach(e => { - const isSelfLoop = e.source_node_uuid === e.target_node_uuid + const isSelfLoop = e.source === e.target if (isSelfLoop) { // 自环边 - 每个节点只添加一条合并的自环 - if (processedSelfLoopNodes.has(e.source_node_uuid)) { + if (processedSelfLoopNodes.has(e.source)) { return // 已处理过,跳过 } - processedSelfLoopNodes.add(e.source_node_uuid) + processedSelfLoopNodes.add(e.source) - const allSelfLoops = selfLoopEdges[e.source_node_uuid] - const nodeName = nodeMap[e.source_node_uuid]?.name || 'Unknown' + const allSelfLoops = selfLoopEdges[e.source] + const nodeName = nodeMap[e.source]?.name || t('graphPanel.unknown') edges.push({ - source: e.source_node_uuid, - target: e.target_node_uuid, + source: e.source, + target: e.target, type: 'SELF_LOOP', name: `Self Relations (${allSelfLoops.length})`, curvature: 0, @@ -423,13 +433,13 @@ const renderGraph = () => { return } - const pairKey = [e.source_node_uuid, e.target_node_uuid].sort().join('_') + const pairKey = [e.source, e.target].sort().join('_') const totalCount = edgePairCount[pairKey] const currentIndex = edgePairIndex[pairKey] || 0 edgePairIndex[pairKey] = currentIndex + 1 // 判断边的方向是否与标准化方向一致(源UUID < 目标UUID) - const isReversed = e.source_node_uuid > e.target_node_uuid + const isReversed = e.source > e.target // 计算曲率:多条边时分散开,单条边为直线 let curvature = 0 @@ -447,18 +457,18 @@ const renderGraph = () => { } edges.push({ - source: e.source_node_uuid, - target: e.target_node_uuid, - type: e.fact_type || e.name || 'RELATED', - name: e.name || e.fact_type || 'RELATED', + source: e.source, + target: e.target, + type: e.type || e.rawData?.fact_type || e.rawData?.name || 'RELATED', + name: e.rawData?.name || e.type || e.rawData?.fact_type || 'RELATED', curvature, isSelfLoop: false, pairIndex: currentIndex, pairTotal: totalCount, rawData: { - ...e, - source_name: nodeMap[e.source_node_uuid]?.name, - target_name: nodeMap[e.target_node_uuid]?.name + ...e.rawData, + source_name: e.rawData?.source_name || nodeMap[e.source]?.name, + target_name: e.rawData?.target_name || nodeMap[e.target]?.name } }) }) diff --git a/frontend/src/components/HistoryDatabase.vue b/frontend/src/components/HistoryDatabase.vue index edc73f46..278c9a0c 100644 --- a/frontend/src/components/HistoryDatabase.vue +++ b/frontend/src/components/HistoryDatabase.vue @@ -13,7 +13,7 @@ <!-- 标题区域 --> <div class="section-header"> <div class="section-line"></div> - <span class="section-title">推演记录</span> + <span class="section-title">{{ t('history.title') }}</span> <div class="section-line"></div> </div> @@ -36,16 +36,16 @@ <span class="status-icon" :class="{ available: project.project_id, unavailable: !project.project_id }" - title="图谱构建" + :title="t('history.graphBuild')" >◇</span> <span class="status-icon available" - title="环境搭建" + :title="t('history.envSetup')" >◈</span> <span class="status-icon" :class="{ available: project.report_id, unavailable: !project.report_id }" - title="分析报告" + :title="t('history.report')" >◆</span> </div> </div> @@ -67,13 +67,13 @@ </div> <!-- 如果有更多文件,显示提示 --> <div v-if="project.files.length > 3" class="files-more"> - +{{ project.files.length - 3 }} 个文件 + +{{ project.files.length - 3 }}{{ t('history.moreFiles') }} </div> </div> <!-- 无文件时的占位 --> <div class="files-empty" v-else> <span class="empty-file-icon">◇</span> - <span class="empty-file-text">暂无文件</span> + <span class="empty-file-text">{{ t('history.noFiles') }}</span> </div> </div> @@ -102,7 +102,7 @@ <!-- 加载状态 --> <div v-if="loading" class="loading-state"> <span class="loading-spinner"></span> - <span class="loading-text">加载中...</span> + <span class="loading-text">{{ t('common.loading') }}</span> </div> <!-- 历史回放详情弹窗 --> @@ -119,34 +119,96 @@ </span> <span class="modal-create-time">{{ formatDate(selectedProject.created_at) }} {{ formatTime(selectedProject.created_at) }}</span> </div> - <button class="modal-close" @click="closeModal">×</button> + <div class="modal-header-actions"> + <button + class="modal-delete" + :disabled="isDeletingSelectedProject" + @click="handleDeleteSelectedProject" + > + {{ isDeletingSelectedProject ? t('history.deleting') : t('history.deleteRecord') }} + </button> + <button class="modal-close" @click="closeModal">×</button> + </div> </div> <!-- 弹窗内容 --> <div class="modal-body"> <!-- 模拟需求 --> <div class="modal-section"> - <div class="modal-label">模拟需求</div> - <div class="modal-requirement">{{ selectedProject.simulation_requirement || '无' }}</div> + <div class="modal-label">{{ t('history.references') }}</div> + <div class="history-reference-grid"> + <div class="history-reference-card"> + <div class="history-reference-heading"> + <span class="history-reference-label">{{ t('history.simulationIdLabel') }}</span> + <button + class="history-copy-btn" + type="button" + @click="copyHistoryReference('simulation', selectedProject.simulation_id)" + > + {{ copiedHistoryField === 'simulation' ? t('history.copied') : t('history.copyId') }} + </button> + </div> + <span class="history-reference-value">{{ selectedProject.simulation_id || t('history.unknownSimulationId') }}</span> + </div> + <div class="history-reference-card"> + <div class="history-reference-heading"> + <span class="history-reference-label">{{ t('history.reportIdLabel') }}</span> + <div class="history-reference-actions"> + <button + class="history-copy-btn" + :disabled="!selectedProject.report_id" + type="button" + @click="copyHistoryReference('report', selectedProject.report_id)" + > + {{ copiedHistoryField === 'report' ? t('history.copied') : t('history.copyId') }} + </button> + <button + class="history-copy-btn" + :disabled="!selectedProject.report_id" + type="button" + @click="downloadSelectedReport" + > + {{ t('history.exportMd') }} + </button> + </div> + </div> + <span class="history-reference-value">{{ selectedProject.report_id || t('step4.unavailableId') }}</span> + </div> + </div> + <div class="history-reference-bundle-row"> + <button + class="history-copy-btn" + :disabled="!selectedProjectVerificationBundle" + type="button" + @click="copyHistoryReference('bundle', selectedProjectVerificationBundle)" + > + {{ copiedHistoryField === 'bundle' ? t('history.copied') : t('history.copyBundle') }} + </button> + </div> + </div> + + <div class="modal-section"> + <div class="modal-label">{{ t('history.simRequirement') }}</div> + <div class="modal-requirement">{{ selectedProject.simulation_requirement || t('common.none') }}</div> </div> <!-- 文件列表 --> <div class="modal-section"> - <div class="modal-label">关联文件</div> + <div class="modal-label">{{ t('history.relatedFiles') }}</div> <div class="modal-files" v-if="selectedProject.files && selectedProject.files.length > 0"> <div v-for="(file, index) in selectedProject.files" :key="index" class="modal-file-item"> <span class="file-tag" :class="getFileType(file.filename)">{{ getFileTypeLabel(file.filename) }}</span> <span class="modal-file-name">{{ file.filename }}</span> </div> </div> - <div class="modal-empty" v-else>暂无关联文件</div> + <div class="modal-empty" v-else>{{ t('history.noRelatedFiles') }}</div> </div> </div> <!-- 推演回放分割线 --> <div class="modal-divider"> <span class="divider-line"></span> - <span class="divider-text">推演回放</span> + <span class="divider-text">{{ t('history.playback') }}</span> <span class="divider-line"></span> </div> @@ -159,7 +221,7 @@ > <span class="btn-step">Step1</span> <span class="btn-icon">◇</span> - <span class="btn-text">图谱构建</span> + <span class="btn-text">{{ t('history.graphBuildBtn') }}</span> </button> <button class="modal-btn btn-simulation" @@ -167,7 +229,16 @@ > <span class="btn-step">Step2</span> <span class="btn-icon">◈</span> - <span class="btn-text">环境搭建</span> + <span class="btn-text">{{ t('history.envSetupBtn') }}</span> + </button> + <button + class="modal-btn btn-simulation-run" + @click="goToSimulationReplay" + :disabled="!canReplaySimulation(selectedProject)" + > + <span class="btn-step">Step3</span> + <span class="btn-icon">◎</span> + <span class="btn-text">{{ t('history.simulationRunBtn') }}</span> </button> <button class="modal-btn btn-report" @@ -176,12 +247,21 @@ > <span class="btn-step">Step4</span> <span class="btn-icon">◆</span> - <span class="btn-text">分析报告</span> + <span class="btn-text">{{ t('history.reportBtn') }}</span> + </button> + <button + class="modal-btn btn-interaction" + @click="goToInteraction" + :disabled="!canOpenInteraction(selectedProject)" + > + <span class="btn-step">Step5</span> + <span class="btn-icon">✦</span> + <span class="btn-text">{{ t('history.interactionBtn') }}</span> </button> </div> <!-- 不可回放提示 --> <div class="modal-playback-hint"> - <span class="hint-text">Step3「开始模拟」与 Step5「深度互动」需在运行中启动,不支持历史回放</span> + <span class="hint-text">{{ t('history.playbackHint') }}</span> </div> </div> </div> @@ -193,10 +273,19 @@ <script setup> import { ref, computed, onMounted, onUnmounted, onActivated, watch, nextTick } from 'vue' import { useRouter, useRoute } from 'vue-router' -import { getSimulationHistory } from '../api/simulation' +import { useI18n } from 'vue-i18n' +import { resolveBaseURL } from '../api/index' +import { deleteSimulationHistory, getSimulationHistory } from '../api/simulation' +import { copyText } from '../utils/clipboard' +import { buildSimulationReplayRoute, hasReplayableSimulationState } from './historyPlayback' +import { truncateFilename as formatHistoryFilename } from './historyFormatters' +import { triggerHistoryReportDownload } from './historyReportDownload' +import { buildInteractionRoute } from './interactionRoute' +import { buildVerificationReferenceBundle } from './verificationBundle' const router = useRouter() const route = useRoute() +const { t } = useI18n() // 状态 const projects = ref([]) @@ -205,10 +294,20 @@ const isExpanded = ref(false) const hoveringCard = ref(null) const historyContainer = ref(null) const selectedProject = ref(null) // 当前选中的项目(用于弹窗) +const deletingSimulationId = ref('') +const copiedHistoryField = ref('') let observer = null let isAnimating = false // 动画锁,防止闪烁 let expandDebounceTimer = null // 防抖定时器 let pendingState = null // 记录待执行的目标状态 +let copiedHistoryTimer = null +const selectedProjectVerificationBundle = computed(() => + buildVerificationReferenceBundle({ + simulationId: selectedProject.value?.simulation_id, + reportId: selectedProject.value?.report_id, + timestamp: selectedProject.value?.created_at, + }) +) // 卡片布局配置 - 调整为更宽的比例 const CARDS_PER_ROW = 4 @@ -337,14 +436,14 @@ const truncateText = (text, maxLength) => { // 从模拟需求生成标题(取前20字) const getSimulationTitle = (requirement) => { - if (!requirement) return '未命名模拟' + if (!requirement) return t('history.unnamedSimulation') const title = requirement.slice(0, 20) return requirement.length > 20 ? title + '...' : title } // 格式化 simulation_id 显示(截取前6位) const formatSimulationId = (simulationId) => { - if (!simulationId) return 'SIM_UNKNOWN' + if (!simulationId) return t('history.unknownSimulationId') const prefix = simulationId.replace('sim_', '').slice(0, 6) return `SIM_${prefix.toUpperCase()}` } @@ -353,8 +452,8 @@ const formatSimulationId = (simulationId) => { const formatRounds = (simulation) => { const current = simulation.current_round || 0 const total = simulation.total_rounds || 0 - if (total === 0) return '未开始' - return `${current}/${total} 轮` + if (total === 0) return t('history.notStarted') + return t('history.roundProgress', { current, total }) } // 获取文件类型(用于样式) @@ -381,15 +480,8 @@ const getFileTypeLabel = (filename) => { } // 截断文件名(保留扩展名) -const truncateFilename = (filename, maxLength) => { - if (!filename) return '未知文件' - if (filename.length <= maxLength) return filename - - const ext = filename.includes('.') ? '.' + filename.split('.').pop() : '' - const nameWithoutExt = filename.slice(0, filename.length - ext.length) - const truncatedName = nameWithoutExt.slice(0, maxLength - ext.length - 3) + '...' - return truncatedName + ext -} +const truncateFilename = (filename, maxLength) => + formatHistoryFilename(filename, maxLength, t('history.unknownFile')) // 打开项目详情弹窗 const navigateToProject = (simulation) => { @@ -399,8 +491,46 @@ const navigateToProject = (simulation) => { // 关闭弹窗 const closeModal = () => { selectedProject.value = null + copiedHistoryField.value = '' } +const clearCopiedHistoryTimer = () => { + if (copiedHistoryTimer) { + window.clearTimeout(copiedHistoryTimer) + copiedHistoryTimer = null + } +} + +const copyHistoryReference = async (field, value) => { + const copied = await copyText(value) + if (!copied) { + return + } + + copiedHistoryField.value = field + clearCopiedHistoryTimer() + copiedHistoryTimer = window.setTimeout(() => { + copiedHistoryField.value = '' + copiedHistoryTimer = null + }, 2000) +} + +const downloadSelectedReport = () => { + const reportId = selectedProject.value?.report_id + if (!reportId) { + return + } + + triggerHistoryReportDownload(reportId, { + simulationId: selectedProject.value?.simulation_id, + baseURL: resolveBaseURL(), + }) +} + +const isDeletingSelectedProject = computed( + () => Boolean(selectedProject.value?.simulation_id) && deletingSimulationId.value === selectedProject.value.simulation_id +) + // 导航到图谱构建页面(Project) const goToProject = () => { if (selectedProject.value?.project_id) { @@ -423,6 +553,23 @@ const goToSimulation = () => { } } +const canReplaySimulation = (simulation) => hasReplayableSimulationState(simulation) + +const getInteractionRoute = (simulation) => + buildInteractionRoute({ + reportId: simulation?.report_id, + simulationId: simulation?.simulation_id, + }) + +const canOpenInteraction = (simulation) => Boolean(getInteractionRoute(simulation)) + +const goToSimulationReplay = () => { + if (selectedProject.value?.simulation_id && canReplaySimulation(selectedProject.value)) { + router.push(buildSimulationReplayRoute(selectedProject.value.simulation_id)) + closeModal() + } +} + // 导航到分析报告页面(Report) const goToReport = () => { if (selectedProject.value?.report_id) { @@ -434,6 +581,42 @@ const goToReport = () => { } } +const goToInteraction = () => { + const interactionRoute = getInteractionRoute(selectedProject.value) + if (!interactionRoute) { + return + } + + router.push(interactionRoute) + closeModal() +} + +const handleDeleteSelectedProject = async () => { + const simulation = selectedProject.value + if (!simulation?.simulation_id || deletingSimulationId.value) { + return + } + + const confirmationMessage = t('history.deleteConfirm', { + simulationId: formatSimulationId(simulation.simulation_id) + }) + if (!window.confirm(confirmationMessage)) { + return + } + + deletingSimulationId.value = simulation.simulation_id + try { + await deleteSimulationHistory(simulation.simulation_id) + projects.value = projects.value.filter((project) => project.simulation_id !== simulation.simulation_id) + closeModal() + } catch (error) { + const message = error?.response?.data?.error || error?.message || t('history.deleteFailed') + window.alert(message) + } finally { + deletingSimulationId.value = '' + } +} + // 加载历史项目 const loadHistory = async () => { try { @@ -555,6 +738,7 @@ onActivated(() => { }) onUnmounted(() => { + clearCopiedHistoryTimer() // 清理 Intersection Observer if (observer) { observer.disconnect() @@ -1082,6 +1266,12 @@ onUnmounted(() => { gap: 16px; } +.modal-header-actions { + display: flex; + align-items: center; + gap: 12px; +} + .modal-id { font-family: 'JetBrains Mono', monospace; font-size: 1rem; @@ -1133,6 +1323,29 @@ onUnmounted(() => { color: #111827; } +.modal-delete { + border: 1px solid rgba(239, 68, 68, 0.2); + background: rgba(254, 226, 226, 0.6); + color: #B91C1C; + border-radius: 8px; + padding: 8px 12px; + font-family: 'JetBrains Mono', monospace; + font-size: 0.75rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.modal-delete:hover:not(:disabled) { + background: rgba(254, 202, 202, 0.9); + border-color: rgba(239, 68, 68, 0.35); +} + +.modal-delete:disabled { + cursor: wait; + opacity: 0.7; +} + /* 弹窗内容 */ .modal-body { padding: 24px 32px; @@ -1166,6 +1379,82 @@ onUnmounted(() => { border-radius: 8px; } +.history-reference-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; +} + +.history-reference-card { + display: flex; + flex-direction: column; + gap: 8px; + padding: 14px 16px; + border: 1px solid #E5E7EB; + border-radius: 8px; + background: #F9FAFB; +} + +.history-reference-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.history-reference-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.history-reference-label { + font-family: 'JetBrains Mono', monospace; + font-size: 0.72rem; + color: #6B7280; + text-transform: uppercase; + letter-spacing: 0.8px; + font-weight: 600; +} + +.history-reference-value { + font-family: 'JetBrains Mono', monospace; + font-size: 0.8rem; + color: #111827; + line-height: 1.5; + word-break: break-word; +} + +.history-copy-btn { + border: 1px solid #D1D5DB; + background: #FFFFFF; + color: #374151; + border-radius: 999px; + padding: 4px 10px; + font-family: 'JetBrains Mono', monospace; + font-size: 0.68rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.history-copy-btn:hover:not(:disabled) { + border-color: #111827; + color: #111827; +} + +.history-copy-btn:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.history-reference-bundle-row { + display: flex; + justify-content: flex-end; + margin-top: 12px; +} + .modal-files { display: flex; flex-direction: column; @@ -1256,6 +1545,7 @@ onUnmounted(() => { /* 导航按钮 */ .modal-actions { display: flex; + flex-wrap: wrap; gap: 16px; padding: 20px 32px; background: #FFFFFF; @@ -1314,7 +1604,9 @@ onUnmounted(() => { .modal-btn.btn-project .btn-icon { color: #3B82F6; } .modal-btn.btn-simulation .btn-icon { color: #F59E0B; } +.modal-btn.btn-simulation-run .btn-icon { color: #EF4444; } .modal-btn.btn-report .btn-icon { color: #10B981; } +.modal-btn.btn-interaction .btn-icon { color: #8B5CF6; } .modal-btn:hover:not(:disabled) .btn-text { color: #111827; diff --git a/frontend/src/components/LanguageSelector.vue b/frontend/src/components/LanguageSelector.vue new file mode 100644 index 00000000..7f53dbab --- /dev/null +++ b/frontend/src/components/LanguageSelector.vue @@ -0,0 +1,77 @@ +<template> + <div class="language-selector" :class="{ light }"> + <button class="lang-btn" :class="{ active: locale === 'zh' }" @click="setLocale('zh')"> + {{ t('nav.zh') }} + </button> + <span class="lang-sep">/</span> + <button class="lang-btn" :class="{ active: locale === 'en' }" @click="setLocale('en')"> + {{ t('nav.en') }} + </button> + </div> +</template> + +<script setup> +import { computed } from 'vue' +import { useI18n } from 'vue-i18n' +import { getStoredLocale, setStoredLocale } from '../i18n' + +const props = defineProps({ + light: { + type: Boolean, + default: false, + }, +}) + +const { locale, t } = useI18n() + +const syncDocumentLanguage = (value) => { + document.documentElement.lang = value === 'en' ? 'en' : 'zh-CN' +} + +const setLocale = (value) => { + locale.value = value + setStoredLocale(value) + syncDocumentLanguage(value) +} + +const light = computed(() => props.light) + +syncDocumentLanguage(getStoredLocale()) +</script> + +<style scoped> +.language-selector { + display: flex; + align-items: center; + gap: 4px; +} + +.lang-btn { + background: none; + border: none; + padding: 4px 8px; + font-size: 0.85rem; + font-weight: 500; + color: #000; + cursor: pointer; + transition: color 0.2s; +} + +.lang-btn:hover { + color: #333; +} + +.lang-btn.active { + font-weight: 700; +} + +.lang-sep { + color: #333; + font-size: 0.75rem; +} + +.language-selector.light .lang-btn, +.language-selector.light .lang-sep { + color: #000; +} +</style> diff --git a/frontend/src/components/Step1GraphBuild.vue b/frontend/src/components/Step1GraphBuild.vue index de33a3fd..d1ff6da2 100644 --- a/frontend/src/components/Step1GraphBuild.vue +++ b/frontend/src/components/Step1GraphBuild.vue @@ -6,32 +6,32 @@ <div class="card-header"> <div class="step-info"> <span class="step-num">01</span> - <span class="step-title">本体生成</span> + <span class="step-title">{{ t('step1Graph.ontologyTitle') }}</span> </div> <div class="step-status"> - <span v-if="currentPhase > 0" class="badge success">已完成</span> - <span v-else-if="currentPhase === 0" class="badge processing">生成中</span> - <span v-else class="badge pending">等待</span> + <span v-if="currentPhase > 0" class="badge success">{{ t('step1Graph.completed') }}</span> + <span v-else-if="currentPhase === 0" class="badge processing">{{ t('step1Graph.generating') }}</span> + <span v-else class="badge pending">{{ t('step1Graph.pending') }}</span> </div> </div> <div class="card-content"> <p class="api-note">POST /api/graph/ontology/generate</p> <p class="description"> - LLM分析文档内容与模拟需求,提取出现实种子,自动生成合适的本体结构 + {{ t('step1Graph.ontologyDescription') }} </p> <!-- Loading / Progress --> <div v-if="currentPhase === 0 && ontologyProgress" class="progress-section"> <div class="spinner-sm"></div> - <span>{{ ontologyProgress.message || '正在分析文档...' }}</span> + <span>{{ ontologyProgress.message || t('step1Graph.analyzingDocuments') }}</span> </div> <!-- Detail Overlay --> <div v-if="selectedOntologyItem" class="ontology-detail-overlay"> <div class="detail-header"> <div class="detail-title-group"> - <span class="detail-type-badge">{{ selectedOntologyItem.itemType === 'entity' ? 'ENTITY' : 'RELATION' }}</span> + <span class="detail-type-badge">{{ selectedOntologyItem.itemType === 'entity' ? t('step1Graph.detailEntityType') : t('step1Graph.detailRelationType') }}</span> <span class="detail-name">{{ selectedOntologyItem.name }}</span> </div> <button class="close-btn" @click="selectedOntologyItem = null">×</button> @@ -41,7 +41,7 @@ <!-- Attributes --> <div class="detail-section" v-if="selectedOntologyItem.attributes?.length"> - <span class="section-label">ATTRIBUTES</span> + <span class="section-label">{{ t('step1Graph.detailAttributes') }}</span> <div class="attr-list"> <div v-for="attr in selectedOntologyItem.attributes" :key="attr.name" class="attr-item"> <span class="attr-name">{{ attr.name }}</span> @@ -53,7 +53,7 @@ <!-- Examples (Entity) --> <div class="detail-section" v-if="selectedOntologyItem.examples?.length"> - <span class="section-label">EXAMPLES</span> + <span class="section-label">{{ t('step1Graph.detailExamples') }}</span> <div class="example-list"> <span v-for="ex in selectedOntologyItem.examples" :key="ex" class="example-tag">{{ ex }}</span> </div> @@ -61,7 +61,7 @@ <!-- Source/Target (Relation) --> <div class="detail-section" v-if="selectedOntologyItem.source_targets?.length"> - <span class="section-label">CONNECTIONS</span> + <span class="section-label">{{ t('step1Graph.detailConnections') }}</span> <div class="conn-list"> <div v-for="(conn, idx) in selectedOntologyItem.source_targets" :key="idx" class="conn-item"> <span class="conn-node">{{ conn.source }}</span> @@ -75,7 +75,7 @@ <!-- Generated Entity Tags --> <div v-if="projectData?.ontology?.entity_types" class="tags-container" :class="{ 'dimmed': selectedOntologyItem }"> - <span class="tag-label">GENERATED ENTITY TYPES</span> + <span class="tag-label">{{ t('step1Graph.generatedEntityTypes') }}</span> <div class="tags-list"> <span v-for="entity in projectData.ontology.entity_types" @@ -90,7 +90,7 @@ <!-- Generated Relation Tags --> <div v-if="projectData?.ontology?.edge_types" class="tags-container" :class="{ 'dimmed': selectedOntologyItem }"> - <span class="tag-label">GENERATED RELATION TYPES</span> + <span class="tag-label">{{ t('step1Graph.generatedRelationTypes') }}</span> <div class="tags-list"> <span v-for="rel in projectData.ontology.edge_types" @@ -110,34 +110,34 @@ <div class="card-header"> <div class="step-info"> <span class="step-num">02</span> - <span class="step-title">GraphRAG构建</span> + <span class="step-title">{{ t('step1Graph.graphBuildTitle') }}</span> </div> <div class="step-status"> - <span v-if="currentPhase > 1" class="badge success">已完成</span> + <span v-if="currentPhase > 1" class="badge success">{{ t('step1Graph.completed') }}</span> <span v-else-if="currentPhase === 1" class="badge processing">{{ buildProgress?.progress || 0 }}%</span> - <span v-else class="badge pending">等待</span> + <span v-else class="badge pending">{{ t('step1Graph.pending') }}</span> </div> </div> <div class="card-content"> <p class="api-note">POST /api/graph/build</p> <p class="description"> - 基于生成的本体,将文档自动分块后调用 Zep 构建知识图谱,提取实体和关系,并形成时序记忆与社区摘要 + {{ t('step1Graph.graphBuildDescription') }} </p> <!-- Stats Cards --> <div class="stats-grid"> <div class="stat-card"> <span class="stat-value">{{ graphStats.nodes }}</span> - <span class="stat-label">实体节点</span> + <span class="stat-label">{{ t('step1Graph.entityNodes') }}</span> </div> <div class="stat-card"> <span class="stat-value">{{ graphStats.edges }}</span> - <span class="stat-label">关系边</span> + <span class="stat-label">{{ t('step1Graph.relationEdges') }}</span> </div> <div class="stat-card"> <span class="stat-value">{{ graphStats.types }}</span> - <span class="stat-label">SCHEMA类型</span> + <span class="stat-label">{{ t('step1Graph.schemaTypes') }}</span> </div> </div> </div> @@ -148,23 +148,23 @@ <div class="card-header"> <div class="step-info"> <span class="step-num">03</span> - <span class="step-title">构建完成</span> + <span class="step-title">{{ t('step1Graph.buildCompleteTitle') }}</span> </div> <div class="step-status"> - <span v-if="currentPhase >= 2" class="badge accent">进行中</span> + <span v-if="currentPhase >= 2" class="badge accent">{{ t('step1Graph.inProgress') }}</span> </div> </div> <div class="card-content"> <p class="api-note">POST /api/simulation/create</p> - <p class="description">图谱构建已完成,请进入下一步进行模拟环境搭建</p> + <p class="description">{{ t('step1Graph.buildCompleteDescription') }}</p> <button class="action-btn" :disabled="currentPhase < 2 || creatingSimulation" @click="handleEnterEnvSetup" > <span v-if="creatingSimulation" class="spinner-sm"></span> - {{ creatingSimulation ? '创建中...' : '进入环境搭建 ➝' }} + {{ creatingSimulation ? t('step1Graph.creatingSimulation') : t('step1Graph.enterEnvSetup') }} </button> </div> </div> @@ -173,8 +173,8 @@ <!-- Bottom Info / Logs --> <div class="system-logs"> <div class="log-header"> - <span class="log-title">SYSTEM DASHBOARD</span> - <span class="log-id">{{ projectData?.project_id || 'NO_PROJECT' }}</span> + <span class="log-title">{{ t('step1Graph.systemDashboard') }}</span> + <span class="log-id">{{ projectData?.project_id || t('step1Graph.noProject') }}</span> </div> <div class="log-content" ref="logContent"> <div class="log-line" v-for="(log, idx) in systemLogs" :key="idx"> @@ -189,9 +189,12 @@ <script setup> import { computed, ref, watch, nextTick } from 'vue' import { useRouter } from 'vue-router' +import { useI18n } from 'vue-i18n' import { createSimulation } from '../api/simulation' +import { summarizeGraphData } from './graphPanelData.js' const router = useRouter() +const { t } = useI18n() const props = defineProps({ currentPhase: { type: Number, default: 0 }, @@ -211,7 +214,7 @@ const creatingSimulation = ref(false) // 进入环境搭建 - 创建 simulation 并跳转 const handleEnterEnvSetup = async () => { if (!props.projectData?.project_id || !props.projectData?.graph_id) { - console.error('缺少项目或图谱信息') + console.error(t('step1Graph.missingProjectOrGraph')) return } @@ -232,12 +235,12 @@ const handleEnterEnvSetup = async () => { params: { simulationId: res.data.simulation_id } }) } else { - console.error('创建模拟失败:', res.error) - alert('创建模拟失败: ' + (res.error || '未知错误')) + console.error(t('step1Graph.createSimulationFailed'), res.error) + alert(t('step1Graph.createSimulationFailedWithMessage', { message: res.error || t('step1Graph.unknownError') })) } } catch (err) { - console.error('创建模拟异常:', err) - alert('创建模拟异常: ' + err.message) + console.error(t('step1Graph.createSimulationException'), err) + alert(t('step1Graph.createSimulationExceptionWithMessage', { message: err.message })) } finally { creatingSimulation.value = false } @@ -248,10 +251,13 @@ const selectOntologyItem = (item, type) => { } const graphStats = computed(() => { - const nodes = props.graphData?.node_count || props.graphData?.nodes?.length || 0 - const edges = props.graphData?.edge_count || props.graphData?.edges?.length || 0 + const summary = summarizeGraphData({ + graphData: props.graphData, + unnamedNodeLabel: t('common.unnamed'), + unknownNodeLabel: t('common.unknown'), + }) const types = props.projectData?.ontology?.entity_types?.length || 0 - return { nodes, edges, types } + return { nodes: summary.nodeCount, edges: summary.edgeCount, types } }) const formatDate = (dateStr) => { diff --git a/frontend/src/components/Step2EnvSetup.vue b/frontend/src/components/Step2EnvSetup.vue index eae776aa..ec0856ab 100644 --- a/frontend/src/components/Step2EnvSetup.vue +++ b/frontend/src/components/Step2EnvSetup.vue @@ -6,36 +6,36 @@ <div class="card-header"> <div class="step-info"> <span class="step-num">01</span> - <span class="step-title">模拟实例初始化</span> + <span class="step-title">{{ t('step2.instanceInitTitle') }}</span> </div> <div class="step-status"> - <span v-if="phase > 0" class="badge success">已完成</span> - <span v-else class="badge processing">初始化</span> + <span v-if="phase > 0" class="badge success">{{ t('step2.completed') }}</span> + <span v-else class="badge processing">{{ t('step2.initializing') }}</span> </div> </div> <div class="card-content"> <p class="api-note">POST /api/simulation/create</p> <p class="description"> - 新建simulation实例,拉取模拟世界参数模版 + {{ t('step2.instanceInitDescription') }} </p> <div v-if="simulationId" class="info-card"> <div class="info-row"> - <span class="info-label">Project ID</span> + <span class="info-label">{{ t('step2.projectIdLabel') }}</span> <span class="info-value mono">{{ projectData?.project_id }}</span> </div> <div class="info-row"> - <span class="info-label">Graph ID</span> + <span class="info-label">{{ t('step2.graphIdLabel') }}</span> <span class="info-value mono">{{ projectData?.graph_id }}</span> </div> <div class="info-row"> - <span class="info-label">Simulation ID</span> + <span class="info-label">{{ t('step2.simulationIdLabel') }}</span> <span class="info-value mono">{{ simulationId }}</span> </div> <div class="info-row"> - <span class="info-label">Task ID</span> - <span class="info-value mono">{{ taskId || '异步任务已完成' }}</span> + <span class="info-label">{{ t('step2.taskIdLabel') }}</span> + <span class="info-value mono">{{ taskId || t('step2.asyncTaskCompleted') }}</span> </div> </div> </div> @@ -46,41 +46,41 @@ <div class="card-header"> <div class="step-info"> <span class="step-num">02</span> - <span class="step-title">生成 Agent 人设</span> + <span class="step-title">{{ t('step2.generateProfilesTitle') }}</span> </div> <div class="step-status"> - <span v-if="phase > 1" class="badge success">已完成</span> + <span v-if="phase > 1" class="badge success">{{ t('step2.completed') }}</span> <span v-else-if="phase === 1" class="badge processing">{{ prepareProgress }}%</span> - <span v-else class="badge pending">等待</span> + <span v-else class="badge pending">{{ t('step2.waiting') }}</span> </div> </div> <div class="card-content"> <p class="api-note">POST /api/simulation/prepare</p> <p class="description"> - 结合上下文,自动调用工具从知识图谱梳理实体与关系,初始化模拟个体,并基于现实种子赋予他们独特的行为与记忆 + {{ t('step2.generateProfilesDescription') }} </p> <!-- Profiles Stats --> <div v-if="profiles.length > 0" class="stats-grid"> <div class="stat-card"> <span class="stat-value">{{ profiles.length }}</span> - <span class="stat-label">当前Agent数</span> + <span class="stat-label">{{ t('step2.currentAgentCount') }}</span> </div> <div class="stat-card"> <span class="stat-value">{{ expectedTotal || '-' }}</span> - <span class="stat-label">预期Agent总数</span> + <span class="stat-label">{{ t('step2.expectedAgentCount') }}</span> </div> <div class="stat-card"> <span class="stat-value">{{ totalTopicsCount }}</span> - <span class="stat-label">现实种子当前关联话题数</span> + <span class="stat-label">{{ t('step2.relatedTopicsCount') }}</span> </div> </div> <!-- Profiles List Preview --> <div v-if="profiles.length > 0" class="profiles-preview"> <div class="preview-header"> - <span class="preview-title">已生成的 Agent 人设</span> + <span class="preview-title">{{ t('step2.generatedProfilesTitle') }}</span> </div> <div class="profiles-list"> <div @@ -90,13 +90,13 @@ @click="selectProfile(profile)" > <div class="profile-header"> - <span class="profile-realname">{{ profile.username || 'Unknown' }}</span> + <span class="profile-realname">{{ profile.username || t('step2.unknownProfileName') }}</span> <span class="profile-username">@{{ profile.name || `agent_${idx}` }}</span> </div> <div class="profile-meta"> - <span class="profile-profession">{{ profile.profession || '未知职业' }}</span> + <span class="profile-profession">{{ profile.profession || t('step2.unknownProfession') }}</span> </div> - <p class="profile-bio">{{ profile.bio || '暂无简介' }}</p> + <p class="profile-bio">{{ profile.bio || t('step2.noBio') }}</p> <div v-if="profile.interested_topics?.length" class="profile-topics"> <span v-for="topic in profile.interested_topics.slice(0, 3)" @@ -118,19 +118,19 @@ <div class="card-header"> <div class="step-info"> <span class="step-num">03</span> - <span class="step-title">生成双平台模拟配置</span> + <span class="step-title">{{ t('step2.generateConfigTitle') }}</span> </div> <div class="step-status"> - <span v-if="phase > 2" class="badge success">已完成</span> - <span v-else-if="phase === 2" class="badge processing">生成中</span> - <span v-else class="badge pending">等待</span> + <span v-if="phase > 2" class="badge success">{{ t('step2.completed') }}</span> + <span v-else-if="phase === 2" class="badge processing">{{ t('step2.generating') }}</span> + <span v-else class="badge pending">{{ t('step2.waiting') }}</span> </div> </div> <div class="card-content"> <p class="api-note">POST /api/simulation/prepare</p> <p class="description"> - LLM 根据模拟需求与现实种子,智能设置世界时间流速、推荐算法、每个个体的活跃时间段、发言频率、事件触发等参数 + {{ t('step2.generateConfigDescription') }} </p> <!-- Config Preview --> @@ -139,40 +139,40 @@ <div class="config-block"> <div class="config-grid"> <div class="config-item"> - <span class="config-item-label">模拟时长</span> - <span class="config-item-value">{{ simulationConfig.time_config?.total_simulation_hours || '-' }} 小时</span> + <span class="config-item-label">{{ t('step2.simulationDuration') }}</span> + <span class="config-item-value">{{ t('step2.hoursValue', { value: simulationConfig.time_config?.total_simulation_hours || '-' }) }}</span> </div> <div class="config-item"> - <span class="config-item-label">每轮时长</span> - <span class="config-item-value">{{ simulationConfig.time_config?.minutes_per_round || '-' }} 分钟</span> + <span class="config-item-label">{{ t('step2.minutesPerRound') }}</span> + <span class="config-item-value">{{ t('step2.minutesValue', { value: simulationConfig.time_config?.minutes_per_round || '-' }) }}</span> </div> <div class="config-item"> - <span class="config-item-label">总轮次</span> - <span class="config-item-value">{{ Math.floor((simulationConfig.time_config?.total_simulation_hours * 60 / simulationConfig.time_config?.minutes_per_round)) || '-' }} 轮</span> + <span class="config-item-label">{{ t('step2.totalRounds') }}</span> + <span class="config-item-value">{{ t('step2.roundsValue', { value: Math.floor((simulationConfig.time_config?.total_simulation_hours * 60 / simulationConfig.time_config?.minutes_per_round)) || '-' }) }}</span> </div> <div class="config-item"> - <span class="config-item-label">每小时活跃</span> + <span class="config-item-label">{{ t('step2.agentsPerHour') }}</span> <span class="config-item-value">{{ simulationConfig.time_config?.agents_per_hour_min }}-{{ simulationConfig.time_config?.agents_per_hour_max }}</span> </div> </div> <div class="time-periods"> <div class="period-item"> - <span class="period-label">高峰时段</span> + <span class="period-label">{{ t('step2.peakHours') }}</span> <span class="period-hours">{{ simulationConfig.time_config?.peak_hours?.join(':00, ') }}:00</span> <span class="period-multiplier">×{{ simulationConfig.time_config?.peak_activity_multiplier }}</span> </div> <div class="period-item"> - <span class="period-label">工作时段</span> + <span class="period-label">{{ t('step2.workHours') }}</span> <span class="period-hours">{{ simulationConfig.time_config?.work_hours?.[0] }}:00-{{ simulationConfig.time_config?.work_hours?.slice(-1)[0] }}:00</span> <span class="period-multiplier">×{{ simulationConfig.time_config?.work_activity_multiplier }}</span> </div> <div class="period-item"> - <span class="period-label">早间时段</span> + <span class="period-label">{{ t('step2.morningHours') }}</span> <span class="period-hours">{{ simulationConfig.time_config?.morning_hours?.[0] }}:00-{{ simulationConfig.time_config?.morning_hours?.slice(-1)[0] }}:00</span> <span class="period-multiplier">×{{ simulationConfig.time_config?.morning_activity_multiplier }}</span> </div> <div class="period-item"> - <span class="period-label">低谷时段</span> + <span class="period-label">{{ t('step2.offPeakHours') }}</span> <span class="period-hours">{{ simulationConfig.time_config?.off_peak_hours?.[0] }}:00-{{ simulationConfig.time_config?.off_peak_hours?.slice(-1)[0] }}:00</span> <span class="period-multiplier">×{{ simulationConfig.time_config?.off_peak_activity_multiplier }}</span> </div> @@ -182,8 +182,8 @@ <!-- Agent 配置 --> <div class="config-block"> <div class="config-block-header"> - <span class="config-block-title">Agent 配置</span> - <span class="config-block-badge">{{ simulationConfig.agent_configs?.length || 0 }} 个</span> + <span class="config-block-title">{{ t('step2.agentConfigTitle') }}</span> + <span class="config-block-badge">{{ t('step2.countValue', { value: simulationConfig.agent_configs?.length || 0 }) }}</span> </div> <div class="agents-cards"> <div @@ -194,7 +194,7 @@ <!-- 卡片头部 --> <div class="agent-card-header"> <div class="agent-identity"> - <span class="agent-id">Agent {{ agent.agent_id }}</span> + <span class="agent-id">{{ t('step2.agentId', { id: agent.agent_id }) }}</span> <span class="agent-name">{{ agent.entity_name }}</span> </div> <div class="agent-tags"> @@ -205,7 +205,7 @@ <!-- 活跃时间轴 --> <div class="agent-timeline"> - <span class="timeline-label">活跃时段</span> + <span class="timeline-label">{{ t('step2.activeHours') }}</span> <div class="mini-timeline"> <div v-for="hour in 24" @@ -228,34 +228,34 @@ <div class="agent-params"> <div class="param-group"> <div class="param-item"> - <span class="param-label">发帖/时</span> + <span class="param-label">{{ t('step2.postsPerHour') }}</span> <span class="param-value">{{ agent.posts_per_hour }}</span> </div> <div class="param-item"> - <span class="param-label">评论/时</span> + <span class="param-label">{{ t('step2.commentsPerHour') }}</span> <span class="param-value">{{ agent.comments_per_hour }}</span> </div> <div class="param-item"> - <span class="param-label">响应延迟</span> - <span class="param-value">{{ agent.response_delay_min }}-{{ agent.response_delay_max }}min</span> + <span class="param-label">{{ t('step2.responseDelay') }}</span> + <span class="param-value">{{ t('step2.delayRangeMinutes', { min: agent.response_delay_min, max: agent.response_delay_max }) }}</span> </div> </div> <div class="param-group"> <div class="param-item"> - <span class="param-label">活跃度</span> + <span class="param-label">{{ t('step2.activityLevel') }}</span> <span class="param-value with-bar"> <span class="mini-bar" :style="{ width: (agent.activity_level * 100) + '%' }"></span> {{ (agent.activity_level * 100).toFixed(0) }}% </span> </div> <div class="param-item"> - <span class="param-label">情感倾向</span> + <span class="param-label">{{ t('step2.sentimentBias') }}</span> <span class="param-value" :class="agent.sentiment_bias > 0 ? 'positive' : agent.sentiment_bias < 0 ? 'negative' : 'neutral'"> {{ agent.sentiment_bias > 0 ? '+' : '' }}{{ agent.sentiment_bias?.toFixed(1) }} </span> </div> <div class="param-item"> - <span class="param-label">影响力</span> + <span class="param-label">{{ t('step2.influenceWeight') }}</span> <span class="param-value highlight">{{ agent.influence_weight?.toFixed(1) }}</span> </div> </div> @@ -267,59 +267,59 @@ <!-- 平台配置 --> <div class="config-block"> <div class="config-block-header"> - <span class="config-block-title">推荐算法配置</span> + <span class="config-block-title">{{ t('step2.recommendationConfigTitle') }}</span> </div> <div class="platforms-grid"> <div v-if="simulationConfig.twitter_config" class="platform-card"> <div class="platform-card-header"> - <span class="platform-name">平台 1:广场 / 信息流</span> + <span class="platform-name">{{ t('step2.platform1Title') }}</span> </div> <div class="platform-params"> <div class="param-row"> - <span class="param-label">时效权重</span> + <span class="param-label">{{ t('step2.recencyWeight') }}</span> <span class="param-value">{{ simulationConfig.twitter_config.recency_weight }}</span> </div> <div class="param-row"> - <span class="param-label">热度权重</span> + <span class="param-label">{{ t('step2.popularityWeight') }}</span> <span class="param-value">{{ simulationConfig.twitter_config.popularity_weight }}</span> </div> <div class="param-row"> - <span class="param-label">相关性权重</span> + <span class="param-label">{{ t('step2.relevanceWeight') }}</span> <span class="param-value">{{ simulationConfig.twitter_config.relevance_weight }}</span> </div> <div class="param-row"> - <span class="param-label">病毒阈值</span> + <span class="param-label">{{ t('step2.viralThreshold') }}</span> <span class="param-value">{{ simulationConfig.twitter_config.viral_threshold }}</span> </div> <div class="param-row"> - <span class="param-label">回音室强度</span> + <span class="param-label">{{ t('step2.echoChamberStrength') }}</span> <span class="param-value">{{ simulationConfig.twitter_config.echo_chamber_strength }}</span> </div> </div> </div> <div v-if="simulationConfig.reddit_config" class="platform-card"> <div class="platform-card-header"> - <span class="platform-name">平台 2:话题 / 社区</span> + <span class="platform-name">{{ t('step2.platform2Title') }}</span> </div> <div class="platform-params"> <div class="param-row"> - <span class="param-label">时效权重</span> + <span class="param-label">{{ t('step2.recencyWeight') }}</span> <span class="param-value">{{ simulationConfig.reddit_config.recency_weight }}</span> </div> <div class="param-row"> - <span class="param-label">热度权重</span> + <span class="param-label">{{ t('step2.popularityWeight') }}</span> <span class="param-value">{{ simulationConfig.reddit_config.popularity_weight }}</span> </div> <div class="param-row"> - <span class="param-label">相关性权重</span> + <span class="param-label">{{ t('step2.relevanceWeight') }}</span> <span class="param-value">{{ simulationConfig.reddit_config.relevance_weight }}</span> </div> <div class="param-row"> - <span class="param-label">病毒阈值</span> + <span class="param-label">{{ t('step2.viralThreshold') }}</span> <span class="param-value">{{ simulationConfig.reddit_config.viral_threshold }}</span> </div> <div class="param-row"> - <span class="param-label">回音室强度</span> + <span class="param-label">{{ t('step2.echoChamberStrength') }}</span> <span class="param-value">{{ simulationConfig.reddit_config.echo_chamber_strength }}</span> </div> </div> @@ -330,7 +330,7 @@ <!-- LLM 配置推理 --> <div v-if="simulationConfig.generation_reasoning" class="config-block"> <div class="config-block-header"> - <span class="config-block-title">LLM 配置推理</span> + <span class="config-block-title">{{ t('step2.llmReasoningTitle') }}</span> </div> <div class="reasoning-content"> <div @@ -351,19 +351,19 @@ <div class="card-header"> <div class="step-info"> <span class="step-num">04</span> - <span class="step-title">初始激活编排</span> + <span class="step-title">{{ t('step2.initialActivationTitle') }}</span> </div> <div class="step-status"> - <span v-if="phase > 3" class="badge success">已完成</span> - <span v-else-if="phase === 3" class="badge processing">编排中</span> - <span v-else class="badge pending">等待</span> + <span v-if="phase > 3" class="badge success">{{ t('step2.completed') }}</span> + <span v-else-if="phase === 3" class="badge processing">{{ t('step2.orchestrating') }}</span> + <span v-else class="badge pending">{{ t('step2.waiting') }}</span> </div> </div> <div class="card-content"> <p class="api-note">POST /api/simulation/prepare</p> <p class="description"> - 基于叙事方向,自动生成初始激活事件与热点话题,引导模拟世界的初始状态 + {{ t('step2.initialActivationDescription') }} </p> <div v-if="simulationConfig?.event_config" class="orchestration-content"> @@ -380,14 +380,14 @@ </linearGradient> </defs> </svg> - 叙事引导方向 + {{ t('step2.narrativeDirection') }} </span> <p class="narrative-text">{{ simulationConfig.event_config.narrative_direction }}</p> </div> <!-- 热点话题 --> <div class="topics-section"> - <span class="box-label">初始热点话题</span> + <span class="box-label">{{ t('step2.initialHotTopics') }}</span> <div class="hot-topics-grid"> <span v-for="topic in simulationConfig.event_config.hot_topics" :key="topic" class="hot-topic-tag"> # {{ topic }} @@ -397,7 +397,7 @@ <!-- 初始帖子流 --> <div class="initial-posts-section"> - <span class="box-label">初始激活序列 ({{ simulationConfig.event_config.initial_posts.length }})</span> + <span class="box-label">{{ t('step2.initialActivationSequence', { count: simulationConfig.event_config.initial_posts.length }) }}</span> <div class="posts-timeline"> <div v-for="(post, idx) in simulationConfig.event_config.initial_posts" :key="idx" class="timeline-item"> <div class="timeline-marker"></div> @@ -405,7 +405,7 @@ <div class="post-header"> <span class="post-role">{{ post.poster_type }}</span> <span class="post-agent-info"> - <span class="post-id">Agent {{ post.poster_agent_id }}</span> + <span class="post-id">{{ t('step2.agentId', { id: post.poster_agent_id }) }}</span> <span class="post-username">@{{ getAgentUsername(post.poster_agent_id) }}</span> </span> </div> @@ -423,29 +423,43 @@ <div class="card-header"> <div class="step-info"> <span class="step-num">05</span> - <span class="step-title">准备完成</span> + <span class="step-title">{{ t('step2.readyTitle') }}</span> </div> <div class="step-status"> - <span v-if="phase >= 4" class="badge processing">进行中</span> - <span v-else class="badge pending">等待</span> + <span v-if="phase >= 4" class="badge processing">{{ t('step2.inProgress') }}</span> + <span v-else class="badge pending">{{ t('step2.waiting') }}</span> </div> </div> <div class="card-content"> <p class="api-note">POST /api/simulation/start</p> - <p class="description">模拟环境已准备完成,可以开始运行模拟</p> + <p class="description">{{ t('step2.readyDescription') }}</p> + + <div v-if="step3RecoveryState" class="step3-recovery-card"> + <div class="step3-recovery-copy"> + <span class="step3-recovery-label">{{ t('step2.savedRunLabel') }}</span> + <p class="step3-recovery-text">{{ t(step3RecoveryState.noticeKey) }}</p> + </div> + <button + class="action-btn secondary recovery" + type="button" + @click="openSavedStep3Run" + > + {{ t(step3RecoveryState.actionKey) }} + </button> + </div> <!-- 模拟轮数配置 - 只有在配置生成完成且轮数计算出来后才显示 --> <div v-if="simulationConfig && autoGeneratedRounds" class="rounds-config-section"> <div class="rounds-header"> <div class="header-left"> - <span class="section-title">模拟轮数设定</span> - <span class="section-desc">MiroFish 自动规划推演现实 <span class="desc-highlight">{{ simulationConfig?.time_config?.total_simulation_hours || '-' }}</span> 小时,每轮代表现实 <span class="desc-highlight">{{ simulationConfig?.time_config?.minutes_per_round || '-' }}</span> 分钟时间流逝</span> + <span class="section-title">{{ t('step2.roundConfigTitle') }}</span> + <span class="section-desc">{{ t('step2.roundConfigDescription', { hours: simulationConfig?.time_config?.total_simulation_hours || '-', minutes: simulationConfig?.time_config?.minutes_per_round || '-' }) }}</span> </div> <label class="switch-control"> <input type="checkbox" v-model="useCustomRounds"> <span class="switch-track"></span> - <span class="switch-label">自定义</span> + <span class="switch-label">{{ t('step2.customMode') }}</span> </label> </div> @@ -454,10 +468,10 @@ <div class="slider-display"> <div class="slider-main-value"> <span class="val-num">{{ customMaxRounds }}</span> - <span class="val-unit">轮</span> + <span class="val-unit">{{ t('step2.roundUnit') }}</span> </div> <div class="slider-meta-info"> - <span>若Agent规模为100:预计耗时约 {{ Math.round(customMaxRounds * 0.6) }} 分钟</span> + <span>{{ t('step2.estimatedRuntime', { minutes: Math.round(customMaxRounds * 0.6) }) }}</span> </div> </div> @@ -478,7 +492,7 @@ :class="{ active: customMaxRounds === 40 }" @click="customMaxRounds = 40" :style="{ position: 'absolute', left: `calc(${(40 - 10) / (autoGeneratedRounds - 10) * 100}% - 30px)` }" - >40 (推荐)</span> + >{{ t('step2.recommendedRounds') }}</span> <span>{{ autoGeneratedRounds }}</span> </div> </div> @@ -488,7 +502,7 @@ <div class="auto-info-card"> <div class="auto-value"> <span class="val-num">{{ autoGeneratedRounds }}</span> - <span class="val-unit">轮</span> + <span class="val-unit">{{ t('step2.roundUnit') }}</span> </div> <div class="auto-content"> <div class="auto-meta-row"> @@ -497,11 +511,11 @@ <circle cx="12" cy="12" r="10"></circle> <polyline points="12 6 12 12 16 14"></polyline> </svg> - 若Agent规模为100:预计耗时 {{ Math.round(autoGeneratedRounds * 0.6) }} 分钟 + {{ t('step2.estimatedRuntime', { minutes: Math.round(autoGeneratedRounds * 0.6) }) }} </span> </div> <div class="auto-desc"> - <p class="highlight-tip" @click="useCustomRounds = true">若首次运行,强烈建议切换至‘自定义模式’减少模拟轮数,以便快速预览效果并降低报错风险 ➝</p> + <p class="highlight-tip" @click="useCustomRounds = true">{{ t('step2.firstRunTip') }}</p> </div> </div> </div> @@ -514,14 +528,14 @@ class="action-btn secondary" @click="$emit('go-back')" > - ← 返回图谱构建 + {{ t('step2.backToGraph') }} </button> <button class="action-btn primary" :disabled="phase < 4" @click="handleStartSimulation" > - 开始双世界并行模拟 ➝ + {{ t('step2.startSimulation') }} </button> </div> </div> @@ -547,32 +561,32 @@ <!-- 基本信息 --> <div class="modal-info-grid"> <div class="info-item"> - <span class="info-label">事件外显年龄</span> - <span class="info-value">{{ selectedProfile.age || '-' }} 岁</span> + <span class="info-label">{{ t('step2.visibleAge') }}</span> + <span class="info-value">{{ t('step2.ageValue', { value: selectedProfile.age || '-' }) }}</span> </div> <div class="info-item"> - <span class="info-label">事件外显性别</span> - <span class="info-value">{{ { male: '男', female: '女', other: '其他' }[selectedProfile.gender] || selectedProfile.gender }}</span> + <span class="info-label">{{ t('step2.visibleGender') }}</span> + <span class="info-value">{{ genderLabel(selectedProfile.gender) }}</span> </div> <div class="info-item"> - <span class="info-label">国家/地区</span> + <span class="info-label">{{ t('step2.countryRegion') }}</span> <span class="info-value">{{ selectedProfile.country || '-' }}</span> </div> <div class="info-item"> - <span class="info-label">事件外显MBTI</span> + <span class="info-label">{{ t('step2.visibleMbti') }}</span> <span class="info-value mbti">{{ selectedProfile.mbti || '-' }}</span> </div> </div> <!-- 简介 --> <div class="modal-section"> - <span class="section-label">人设简介</span> - <p class="section-bio">{{ selectedProfile.bio || '暂无简介' }}</p> + <span class="section-label">{{ t('step2.profileBio') }}</span> + <p class="section-bio">{{ selectedProfile.bio || t('step2.noBio') }}</p> </div> <!-- 关注话题 --> <div class="modal-section" v-if="selectedProfile.interested_topics?.length"> - <span class="section-label">现实种子关联话题</span> + <span class="section-label">{{ t('step2.relatedTopics') }}</span> <div class="topics-grid"> <span v-for="topic in selectedProfile.interested_topics" @@ -584,25 +598,25 @@ <!-- 详细人设 --> <div class="modal-section" v-if="selectedProfile.persona"> - <span class="section-label">详细人设背景</span> + <span class="section-label">{{ t('step2.detailedPersona') }}</span> <!-- 人设维度概览 --> <div class="persona-dimensions"> <div class="dimension-card"> - <span class="dim-title">事件全景经历</span> - <span class="dim-desc">在此事件中的完整行为轨迹</span> + <span class="dim-title">{{ t('step2.personaDimensions.eventJourney.title') }}</span> + <span class="dim-desc">{{ t('step2.personaDimensions.eventJourney.desc') }}</span> </div> <div class="dimension-card"> - <span class="dim-title">行为模式侧写</span> - <span class="dim-desc">经验总结与行事风格偏好</span> + <span class="dim-title">{{ t('step2.personaDimensions.behaviorPattern.title') }}</span> + <span class="dim-desc">{{ t('step2.personaDimensions.behaviorPattern.desc') }}</span> </div> <div class="dimension-card"> - <span class="dim-title">独特记忆印记</span> - <span class="dim-desc">基于现实种子形成的记忆</span> + <span class="dim-title">{{ t('step2.personaDimensions.memoryImprint.title') }}</span> + <span class="dim-desc">{{ t('step2.personaDimensions.memoryImprint.desc') }}</span> </div> <div class="dimension-card"> - <span class="dim-title">社会关系网络</span> - <span class="dim-desc">个体链接与交互图谱</span> + <span class="dim-title">{{ t('step2.personaDimensions.socialGraph.title') }}</span> + <span class="dim-desc">{{ t('step2.personaDimensions.socialGraph.desc') }}</span> </div> </div> @@ -618,8 +632,8 @@ <!-- Bottom Info / Logs --> <div class="system-logs"> <div class="log-header"> - <span class="log-title">SYSTEM DASHBOARD</span> - <span class="log-id">{{ simulationId || 'NO_SIMULATION' }}</span> + <span class="log-title">{{ t('step2.systemDashboard') }}</span> + <span class="log-id">{{ simulationId || t('step2.noSimulation') }}</span> </div> <div class="log-content" ref="logContent"> <div class="log-line" v-for="(log, idx) in systemLogs" :key="idx"> @@ -633,6 +647,8 @@ <script setup> import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue' +import { useI18n } from 'vue-i18n' +import { useRouter } from 'vue-router' import { prepareSimulation, getPrepareStatus, @@ -640,9 +656,15 @@ import { getSimulationConfig, getSimulationConfigRealtime } from '../api/simulation' +import { formatPrepareProgressLog } from './simulationLogMessages' +import { getStep2RecoveryState } from './step2Recovery' + +const { t } = useI18n() +const router = useRouter() const props = defineProps({ simulationId: String, // 从父组件传入 + simulationData: Object, projectData: Object, graphData: Object, systemLogs: Array @@ -680,7 +702,7 @@ watch(currentStage, (newStage) => { phase.value = 2 // 进入配置生成阶段,开始轮询配置 if (!configTimer) { - addLog('开始生成双平台模拟配置...') + addLog(t('step2.logs.generatingSimulationConfig')) startConfigPolling() } } else if (newStage === '准备模拟脚本' || newStage === 'copying_scripts') { @@ -703,6 +725,11 @@ const autoGeneratedRounds = computed(() => { return Math.max(calculatedRounds, 40) }) +const step3RecoveryState = computed(() => getStep2RecoveryState({ + ...(props.simulationData || {}), + simulation_id: props.simulationId, +})) + // Polling timer let pollTimer = null let profilesTimer = null @@ -716,6 +743,16 @@ const displayProfiles = computed(() => { return profiles.value.slice(0, 6) }) +const genderLabel = (gender) => { + const labels = { + male: t('step2.gender.male'), + female: t('step2.gender.female'), + other: t('step2.gender.other') + } + + return labels[gender] || gender || '-' +} + // 根据agent_id获取对应的username const getAgentUsername = (agentId) => { if (profiles.value && profiles.value.length > agentId && agentId >= 0) { @@ -745,15 +782,23 @@ const handleStartSimulation = () => { if (useCustomRounds.value) { // 用户自定义轮数,传递 max_rounds 参数 params.maxRounds = customMaxRounds.value - addLog(`开始模拟,自定义轮数: ${customMaxRounds.value} 轮`) + addLog(t('step2.logs.startSimulationCustomRounds', { count: customMaxRounds.value })) } else { // 用户选择保持自动生成的轮数,不传递 max_rounds 参数 - addLog(`开始模拟,使用自动配置轮数: ${autoGeneratedRounds.value} 轮`) + addLog(t('step2.logs.startSimulationAutoRounds', { count: autoGeneratedRounds.value })) } emit('next-step', params) } +const openSavedStep3Run = () => { + if (!step3RecoveryState.value?.route) { + return + } + + router.push(step3RecoveryState.value.route) +} + const truncateBio = (bio) => { if (bio.length > 80) { return bio.substring(0, 80) + '...' @@ -768,15 +813,15 @@ const selectProfile = (profile) => { // 自动开始准备模拟 const startPrepareSimulation = async () => { if (!props.simulationId) { - addLog('错误:缺少 simulationId') + addLog(t('step2.logs.missingSimulationId')) emit('update-status', 'error') return } // 标记第一步完成,开始第二步 phase.value = 1 - addLog(`模拟实例已创建: ${props.simulationId}`) - addLog('正在准备模拟环境...') + addLog(t('step2.logs.instanceCreated', { id: props.simulationId })) + addLog(t('step2.logs.preparingEnvironment')) emit('update-status', 'processing') try { @@ -788,35 +833,35 @@ const startPrepareSimulation = async () => { if (res.success && res.data) { if (res.data.already_prepared) { - addLog('检测到已有完成的准备工作,直接使用') + addLog(t('step2.logs.reusePreparedData')) await loadPreparedData() return } taskId.value = res.data.task_id - addLog(`准备任务已启动`) - addLog(` └─ Task ID: ${res.data.task_id}`) + addLog(t('step2.logs.prepareTaskStarted')) + addLog(t('step2.logs.taskId', { id: res.data.task_id })) // 立即设置预期Agent总数(从prepare接口返回值获取) if (res.data.expected_entities_count) { expectedTotal.value = res.data.expected_entities_count - addLog(`从Zep图谱读取到 ${res.data.expected_entities_count} 个实体`) + addLog(t('step2.logs.entityCount', { count: res.data.expected_entities_count })) if (res.data.entity_types && res.data.entity_types.length > 0) { - addLog(` └─ 实体类型: ${res.data.entity_types.join(', ')}`) + addLog(t('step2.logs.entityTypes', { types: res.data.entity_types.join(', ') })) } } - addLog('开始轮询准备进度...') + addLog(t('step2.logs.startPolling')) // 开始轮询进度 startPolling() // 开始实时获取 Profiles startProfilesPolling() } else { - addLog(`准备失败: ${res.error || '未知错误'}`) + addLog(t('step2.logs.prepareFailed', { message: res.error || t('step2.unknownError') })) emit('update-status', 'error') } } catch (err) { - addLog(`准备异常: ${err.message}`) + addLog(t('step2.logs.prepareException', { message: err.message })) emit('update-status', 'error') } } @@ -860,7 +905,7 @@ const pollPrepareStatus = async () => { progressMessage.value = data.message || '' // 解析阶段信息并输出详细日志 - if (data.progress_detail) { + if (data.progress_detail) { currentStage.value = data.progress_detail.current_stage_name || '' // 输出详细进度日志(避免重复) @@ -868,11 +913,9 @@ const pollPrepareStatus = async () => { const logKey = `${detail.current_stage}-${detail.current_item}-${detail.total_items}` if (logKey !== lastLoggedMessage && detail.item_description) { lastLoggedMessage = logKey - const stageInfo = `[${detail.stage_index}/${detail.total_stages}]` - if (detail.total_items > 0) { - addLog(`${stageInfo} ${detail.current_stage_name}: ${detail.current_item}/${detail.total_items} - ${detail.item_description}`) - } else { - addLog(`${stageInfo} ${detail.current_stage_name}: ${detail.item_description}`) + const progressLog = formatPrepareProgressLog(detail, t) + if (progressLog) { + addLog(progressLog) } } } else if (data.message) { @@ -890,12 +933,12 @@ const pollPrepareStatus = async () => { // 检查是否完成 if (data.status === 'completed' || data.status === 'ready' || data.already_prepared) { - addLog('✓ 准备工作已完成') + addLog(t('step2.logs.prepareCompleted')) stopPolling() stopProfilesPolling() await loadPreparedData() } else if (data.status === 'failed') { - addLog(`✗ 准备失败: ${data.error || '未知错误'}`) + addLog(t('step2.logs.prepareFailedMarked', { message: data.error || t('step2.unknownError') })) stopPolling() stopProfilesPolling() } @@ -909,7 +952,7 @@ const fetchProfilesRealtime = async () => { if (!props.simulationId) return try { - const res = await getSimulationProfilesRealtime(props.simulationId, 'reddit') + const res = await getSimulationProfilesRealtime(props.simulationId) if (res.success && res.data) { const prevCount = profiles.value.length @@ -934,13 +977,13 @@ const fetchProfilesRealtime = async () => { const latestProfile = profiles.value[currentCount - 1] const profileName = latestProfile?.name || latestProfile?.username || `Agent_${currentCount}` if (currentCount === 1) { - addLog(`开始生成Agent人设...`) + addLog(t('step2.logs.generatingProfiles')) } - addLog(`→ Agent人设 ${currentCount}/${total}: ${profileName} (${latestProfile?.profession || '未知职业'})`) + addLog(t('step2.logs.profileProgress', { current: currentCount, total, name: profileName, profession: latestProfile?.profession || t('step2.unknownProfession') })) // 如果全部生成完成 if (expectedTotal.value && currentCount >= expectedTotal.value) { - addLog(`✓ 全部 ${currentCount} 个Agent人设生成完成`) + addLog(t('step2.logs.allProfilesCompleted', { count: currentCount })) } } } @@ -974,41 +1017,41 @@ const fetchConfigRealtime = async () => { if (data.generation_stage && data.generation_stage !== lastLoggedConfigStage) { lastLoggedConfigStage = data.generation_stage if (data.generation_stage === 'generating_profiles') { - addLog('正在生成Agent人设配置...') + addLog(t('step2.logs.generatingProfileConfig')) } else if (data.generation_stage === 'generating_config') { - addLog('正在调用LLM生成模拟配置参数...') + addLog(t('step2.logs.generatingSimulationConfig')) } } // 如果配置已生成 if (data.config_generated && data.config) { simulationConfig.value = data.config - addLog('✓ 模拟配置生成完成') + addLog(t('step2.logs.configGenerated')) // 显示详细配置摘要 if (data.summary) { - addLog(` ├─ Agent数量: ${data.summary.total_agents}个`) - addLog(` ├─ 模拟时长: ${data.summary.simulation_hours}小时`) - addLog(` ├─ 初始帖子: ${data.summary.initial_posts_count}条`) - addLog(` ├─ 热点话题: ${data.summary.hot_topics_count}个`) - addLog(` └─ 平台配置: Twitter ${data.summary.has_twitter_config ? '✓' : '✗'}, Reddit ${data.summary.has_reddit_config ? '✓' : '✗'}`) + addLog(t('step2.logs.summaryAgents', { count: data.summary.total_agents })) + addLog(t('step2.logs.summaryHours', { hours: data.summary.simulation_hours })) + addLog(t('step2.logs.summaryPosts', { count: data.summary.initial_posts_count })) + addLog(t('step2.logs.summaryTopics', { count: data.summary.hot_topics_count })) + addLog(t('step2.logs.summaryPlatforms', { twitter: data.summary.has_twitter_config ? '✓' : '✗', reddit: data.summary.has_reddit_config ? '✓' : '✗' })) } // 显示时间配置详情 if (data.config.time_config) { const tc = data.config.time_config - addLog(`时间配置: 每轮${tc.minutes_per_round}分钟, 共${Math.floor((tc.total_simulation_hours * 60) / tc.minutes_per_round)}轮`) + addLog(t('step2.logs.timeConfig', { minutes: tc.minutes_per_round, rounds: Math.floor((tc.total_simulation_hours * 60) / tc.minutes_per_round) })) } // 显示事件配置 if (data.config.event_config?.narrative_direction) { const narrative = data.config.event_config.narrative_direction - addLog(`叙事方向: ${narrative.length > 50 ? narrative.substring(0, 50) + '...' : narrative}`) + addLog(t('step2.logs.narrativeDirection', { narrative: narrative.length > 50 ? narrative.substring(0, 50) + '...' : narrative })) } stopConfigPolling() phase.value = 4 - addLog('✓ 环境搭建完成,可以开始模拟') + addLog(t('step2.logs.environmentReady')) emit('update-status', 'completed') } } @@ -1019,11 +1062,11 @@ const fetchConfigRealtime = async () => { const loadPreparedData = async () => { phase.value = 2 - addLog('正在加载已有配置数据...') + addLog(t('step2.logs.loadingPreparedData')) // 最后获取一次 Profiles await fetchProfilesRealtime() - addLog(`已加载 ${profiles.value.length} 个Agent人设`) + addLog(t('step2.logs.loadedProfiles', { count: profiles.value.length })) // 获取配置(使用实时接口) try { @@ -1031,26 +1074,26 @@ const loadPreparedData = async () => { if (res.success && res.data) { if (res.data.config_generated && res.data.config) { simulationConfig.value = res.data.config - addLog('✓ 模拟配置加载成功') + addLog(t('step2.logs.configLoaded')) // 显示详细配置摘要 if (res.data.summary) { - addLog(` ├─ Agent数量: ${res.data.summary.total_agents}个`) - addLog(` ├─ 模拟时长: ${res.data.summary.simulation_hours}小时`) - addLog(` └─ 初始帖子: ${res.data.summary.initial_posts_count}条`) + addLog(t('step2.logs.summaryAgents', { count: res.data.summary.total_agents })) + addLog(t('step2.logs.summaryHours', { hours: res.data.summary.simulation_hours })) + addLog(t('step2.logs.summaryPostsOnly', { count: res.data.summary.initial_posts_count })) } - addLog('✓ 环境搭建完成,可以开始模拟') + addLog(t('step2.logs.environmentReady')) phase.value = 4 emit('update-status', 'completed') } else { // 配置尚未生成,开始轮询 - addLog('配置生成中,开始轮询等待...') + addLog(t('step2.logs.configPolling')) startConfigPolling() } } } catch (err) { - addLog(`加载配置失败: ${err.message}`) + addLog(t('step2.logs.loadConfigFailed', { message: err.message })) emit('update-status', 'error') } } @@ -1068,7 +1111,7 @@ watch(() => props.systemLogs?.length, () => { onMounted(() => { // 自动开始准备流程 if (props.simulationId) { - addLog('Step2 环境搭建初始化') + addLog(t('step2.logs.init')) startPrepareSimulation() } }) @@ -1232,6 +1275,43 @@ onUnmounted(() => { width: 100%; } +.step3-recovery-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; + padding: 16px 18px; + border: 1px solid #E6D6BA; + border-radius: 10px; + background: linear-gradient(135deg, #FFF8ED 0%, #FFFCF6 100%); +} + +.step3-recovery-copy { + display: flex; + flex-direction: column; + gap: 6px; +} + +.step3-recovery-label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #9A5B16; +} + +.step3-recovery-text { + margin: 0; + font-size: 13px; + line-height: 1.5; + color: #53320A; +} + +.action-btn.recovery { + white-space: nowrap; +} + /* Info Card */ .info-card { background: #F5F5F5; @@ -2560,6 +2640,17 @@ onUnmounted(() => { text-decoration: underline; } +@media (max-width: 720px) { + .step3-recovery-card { + flex-direction: column; + align-items: stretch; + } + + .action-btn.recovery { + width: 100%; + } +} + @keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } diff --git a/frontend/src/components/Step3Simulation.vue b/frontend/src/components/Step3Simulation.vue index 74d0e1e7..355b6aa6 100644 --- a/frontend/src/components/Step3Simulation.vue +++ b/frontend/src/components/Step3Simulation.vue @@ -9,7 +9,7 @@ <svg class="platform-icon" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"> <circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path> </svg> - <span class="platform-name">Info Plaza</span> + <span class="platform-name">{{ getPlatformName('twitter') }}</span> <span v-if="runStatus.twitter_completed" class="status-badge"> <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="3"> <polyline points="20 6 9 17 4 12"></polyline> @@ -18,28 +18,27 @@ </div> <div class="platform-stats"> <span class="stat"> - <span class="stat-label">ROUND</span> + <span class="stat-label">{{ t('step3.round') }}</span> <span class="stat-value mono">{{ runStatus.twitter_current_round || 0 }}<span class="stat-total">/{{ runStatus.total_rounds || maxRounds || '-' }}</span></span> </span> <span class="stat"> - <span class="stat-label">Elapsed Time</span> + <span class="stat-label">{{ t('step3.elapsedTime') }}</span> <span class="stat-value mono">{{ twitterElapsedTime }}</span> </span> <span class="stat"> - <span class="stat-label">ACTS</span> + <span class="stat-label">{{ t('step3.acts') }}</span> <span class="stat-value mono">{{ runStatus.twitter_actions_count || 0 }}</span> </span> </div> <!-- 可用动作提示 --> <div class="actions-tooltip"> - <div class="tooltip-title">Available Actions</div> + <div class="tooltip-title">{{ t('step3.availableActions') }}</div> <div class="tooltip-actions"> - <span class="tooltip-action">POST</span> - <span class="tooltip-action">LIKE</span> - <span class="tooltip-action">REPOST</span> - <span class="tooltip-action">QUOTE</span> - <span class="tooltip-action">FOLLOW</span> - <span class="tooltip-action">IDLE</span> + <span + v-for="actionLabel in getPlatformActions('twitter')" + :key="`twitter-${actionLabel}`" + class="tooltip-action" + >{{ actionLabel }}</span> </div> </div> </div> @@ -50,7 +49,7 @@ <svg class="platform-icon" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"> <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path> </svg> - <span class="platform-name">Topic Community</span> + <span class="platform-name">{{ getPlatformName('reddit') }}</span> <span v-if="runStatus.reddit_completed" class="status-badge"> <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="3"> <polyline points="20 6 9 17 4 12"></polyline> @@ -59,56 +58,77 @@ </div> <div class="platform-stats"> <span class="stat"> - <span class="stat-label">ROUND</span> + <span class="stat-label">{{ t('step3.round') }}</span> <span class="stat-value mono">{{ runStatus.reddit_current_round || 0 }}<span class="stat-total">/{{ runStatus.total_rounds || maxRounds || '-' }}</span></span> </span> <span class="stat"> - <span class="stat-label">Elapsed Time</span> + <span class="stat-label">{{ t('step3.elapsedTime') }}</span> <span class="stat-value mono">{{ redditElapsedTime }}</span> </span> <span class="stat"> - <span class="stat-label">ACTS</span> + <span class="stat-label">{{ t('step3.acts') }}</span> <span class="stat-value mono">{{ runStatus.reddit_actions_count || 0 }}</span> </span> </div> <!-- 可用动作提示 --> <div class="actions-tooltip"> - <div class="tooltip-title">Available Actions</div> + <div class="tooltip-title">{{ t('step3.availableActions') }}</div> <div class="tooltip-actions"> - <span class="tooltip-action">POST</span> - <span class="tooltip-action">COMMENT</span> - <span class="tooltip-action">LIKE</span> - <span class="tooltip-action">DISLIKE</span> - <span class="tooltip-action">SEARCH</span> - <span class="tooltip-action">TREND</span> - <span class="tooltip-action">FOLLOW</span> - <span class="tooltip-action">MUTE</span> - <span class="tooltip-action">REFRESH</span> - <span class="tooltip-action">IDLE</span> + <span + v-for="actionLabel in getPlatformActions('reddit')" + :key="`reddit-${actionLabel}`" + class="tooltip-action" + >{{ actionLabel }}</span> </div> </div> </div> </div> <div class="action-controls"> + <button + v-if="phase !== 1" + class="action-btn secondary" + :disabled="isStarting || isGeneratingReport" + @click="handleRestartSimulation" + > + <span v-if="isStarting" class="loading-spinner-small"></span> + {{ isStarting ? t('step3.starting') : t(restartButtonLabelKey) }} + </button> <button class="action-btn primary" :disabled="phase !== 2 || isGeneratingReport" @click="handleNextStep" > <span v-if="isGeneratingReport" class="loading-spinner-small"></span> - {{ isGeneratingReport ? '启动中...' : '开始生成结果报告' }} + {{ isGeneratingReport ? t('step3.starting') : t('step3.startReport') }} <span v-if="!isGeneratingReport" class="arrow-icon">→</span> </button> </div> </div> + <div v-if="replayNoticeKey" class="replay-notice"> + <span class="replay-notice-label">{{ t('step3.replayOnlyLabel') }}</span> + <span class="replay-notice-text">{{ t(replayNoticeKey) }}</span> + </div> + + <div v-if="interactionShortcutMessage && simulationId" class="interaction-shortcut-notice"> + <div class="interaction-shortcut-copy"> + <span class="interaction-shortcut-label">{{ t('step3.interactionShortcutLabel') }}</span> + <span class="interaction-shortcut-text"> + {{ t('step3.interactionShortcutHint', { message: interactionShortcutMessage }) }} + </span> + </div> + <button class="interaction-shortcut-btn" type="button" @click="openInteractionShortcut"> + {{ t('step3.openInteractionShortcutButton') }} + </button> + </div> + <!-- Main Content: Dual Timeline --> <div class="main-content-area" ref="scrollContainer"> <!-- Timeline Header --> <div class="timeline-header" v-if="allActions.length > 0"> <div class="timeline-stats"> - <span class="total-count">TOTAL EVENTS: <span class="mono">{{ allActions.length }}</span></span> + <span class="total-count">{{ t('step3.totalEvents') }}: <span class="mono">{{ allActions.length }}</span></span> <span class="platform-breakdown"> <span class="breakdown-item twitter"> <svg class="mini-icon" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg> @@ -170,7 +190,7 @@ <div v-if="action.action_args?.original_content" class="quoted-block"> <div class="quote-header"> <svg class="icon-small" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg> - <span class="quote-label">@{{ action.action_args.original_author_name || 'User' }}</span> + <span class="quote-label">@{{ action.action_args.original_author_name || t('step3.unknownUser') }}</span> </div> <div class="quote-text"> {{ truncateContent(action.action_args.original_content, 150) }} @@ -182,7 +202,7 @@ <template v-if="action.action_type === 'REPOST'"> <div class="repost-info"> <svg class="icon-small" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><polyline points="17 1 21 5 17 9"></polyline><path d="M3 11V9a4 4 0 0 1 4-4h14"></path><polyline points="7 23 3 19 7 15"></polyline><path d="M21 13v2a4 4 0 0 1-4 4H3"></path></svg> - <span class="repost-label">Reposted from @{{ action.action_args?.original_author_name || 'User' }}</span> + <span class="repost-label">{{ describeAction(action) }}</span> </div> <div v-if="action.action_args?.original_content" class="repost-content"> {{ truncateContent(action.action_args.original_content, 200) }} @@ -193,7 +213,7 @@ <template v-if="action.action_type === 'LIKE_POST'"> <div class="like-info"> <svg class="icon-small filled" viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path></svg> - <span class="like-label">Liked @{{ action.action_args?.post_author_name || 'User' }}'s post</span> + <span class="like-label">{{ describeAction(action) }}</span> </div> <div v-if="action.action_args?.post_content" class="liked-content"> "{{ truncateContent(action.action_args.post_content, 120) }}" @@ -207,7 +227,7 @@ </div> <div v-if="action.action_args?.post_id" class="comment-context"> <svg class="icon-small" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path></svg> - <span>Reply to post #{{ action.action_args.post_id }}</span> + <span>{{ describeAction(action) }}</span> </div> </template> @@ -215,7 +235,7 @@ <template v-if="action.action_type === 'SEARCH_POSTS'"> <div class="search-info"> <svg class="icon-small" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg> - <span class="search-label">Search Query:</span> + <span class="search-label">{{ describeAction(action) }}</span> <span class="search-query">"{{ action.action_args?.query || '' }}"</span> </div> </template> @@ -224,7 +244,7 @@ <template v-if="action.action_type === 'FOLLOW'"> <div class="follow-info"> <svg class="icon-small" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="8.5" cy="7" r="4"></circle><line x1="20" y1="8" x2="20" y2="14"></line><line x1="23" y1="11" x2="17" y2="11"></line></svg> - <span class="follow-label">Followed @{{ action.action_args?.target_user || action.action_args?.user_id || 'User' }}</span> + <span class="follow-label">{{ describeAction(action) }}</span> </div> </template> @@ -233,7 +253,7 @@ <div class="vote-info"> <svg v-if="action.action_type === 'UPVOTE_POST'" class="icon-small" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><polyline points="18 15 12 9 6 15"></polyline></svg> <svg v-else class="icon-small" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"></polyline></svg> - <span class="vote-label">{{ action.action_type === 'UPVOTE_POST' ? 'Upvoted' : 'Downvoted' }} Post</span> + <span class="vote-label">{{ describeAction(action) }}</span> </div> <div v-if="action.action_args?.post_content" class="voted-content"> "{{ truncateContent(action.action_args.post_content, 120) }}" @@ -244,7 +264,7 @@ <template v-if="action.action_type === 'DO_NOTHING'"> <div class="idle-info"> <svg class="icon-small" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg> - <span class="idle-label">Action Skipped</span> + <span class="idle-label">{{ describeAction(action) }}</span> </div> </template> @@ -264,7 +284,26 @@ <div v-if="allActions.length === 0" class="waiting-state"> <div class="pulse-ring"></div> - <span>Waiting for agent actions...</span> + <span>{{ t('step3.waitingActions') }}</span> + <div v-if="waitingDiagnostics" class="waiting-diagnostics"> + <p class="waiting-hint"> + {{ + waitingDiagnostics.process_alive + ? t('step3.waitingDiagnosticsProcessAlive') + : t('step3.waitingDiagnosticsProcessExited') + }} + </p> + <p class="waiting-meta"> + {{ t('step3.waitingDiagnosticsStatus', { status: waitingStatusLabel }) }} + <span v-if="waitingDiagnostics.process_pid"> + · {{ t('step3.waitingDiagnosticsPid', { pid: waitingDiagnostics.process_pid }) }} + </span> + </p> + <pre + v-if="waitingDiagnostics.simulation_log_tail" + class="waiting-log-tail" + >{{ waitingDiagnostics.simulation_log_tail }}</pre> + </div> </div> </div> </div> @@ -272,7 +311,7 @@ <!-- Bottom Info / Logs --> <div class="system-logs"> <div class="log-header"> - <span class="log-title">SIMULATION MONITOR</span> + <span class="log-title">{{ t('step3.monitor') }}</span> <span class="log-id">{{ simulationId || 'NO_SIMULATION' }}</span> </div> <div class="log-content" ref="logContent"> @@ -287,7 +326,11 @@ <script setup> import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue' +import { useI18n } from 'vue-i18n' import { useRouter } from 'vue-router' +import { resolveBaseURL } from '../api/index.js' +import { formatApiError } from '../api/errors' +import { getBackendConfigStatus } from '../api/graph' import { startSimulation, stopSimulation, @@ -295,6 +338,24 @@ import { getRunStatusDetail } from '../api/simulation' import { generateReport } from '../api/report' +import { buildInteractionRoute } from './interactionRoute' +import { getReportPreflightBlockReason } from './reportCapability' +import { + describeTimelineAction, + getTimelineActionTypeLabel, + getTimelineAvailableActions, + getTimelinePlatformName, +} from './simulationTimeline' +import { mergeLiveActions } from './liveActionBuffer' +import { + getReplayNoticeKey, + getRestartButtonLabelKey, + shouldAutoStartSimulation, +} from './simulationReplay' +import { + formatSimulationPidLog, + formatSimulationRoundLog, +} from './simulationLogMessages' const props = defineProps({ simulationId: String, @@ -303,6 +364,10 @@ const props = defineProps({ type: Number, default: 30 // 默认每轮30分钟 }, + replayOnly: { + type: Boolean, + default: false + }, projectData: Object, graphData: Object, systemLogs: Array @@ -311,9 +376,11 @@ const props = defineProps({ const emit = defineEmits(['go-back', 'next-step', 'add-log', 'update-status']) const router = useRouter() +const { t, locale } = useI18n() // State const isGeneratingReport = ref(false) +const interactionShortcutMessage = ref('') const phase = ref(0) // 0: 未开始, 1: 运行中, 2: 已完成 const isStarting = ref(false) const isStopping = ref(false) @@ -321,7 +388,9 @@ const startError = ref(null) const runStatus = ref({}) const allActions = ref([]) // 所有动作(增量累积) const actionIds = ref(new Set()) // 用于去重的动作ID集合 +const latestActionTimestamp = ref('') const scrollContainer = ref(null) +const resumedExistingRun = ref(false) // Computed // 按时间顺序显示动作(最新的在最后面,即底部) @@ -338,6 +407,37 @@ const redditActionsCount = computed(() => { return allActions.value.filter(a => a.platform === 'reddit').length }) +const waitingDiagnostics = computed(() => { + const diagnostics = runStatus.value?.waiting_diagnostics + if (!diagnostics?.waiting_for_actions) { + return null + } + return diagnostics +}) + +const waitingStatusLabel = computed(() => { + const status = runStatus.value?.runner_status + if (status === 'starting') { + return t('step3.waitingDiagnosticsStatusStarting') + } + if (status === 'running') { + return t('step3.waitingDiagnosticsStatusRunning') + } + return status || t('common.none') +}) + +const replayNoticeKey = computed(() => getReplayNoticeKey({ + replayOnly: props.replayOnly, + resumed: resumedExistingRun.value, + runnerStatus: runStatus.value?.runner_status, +})) + +const restartButtonLabelKey = computed(() => getRestartButtonLabelKey({ + replayOnly: props.replayOnly, + resumed: resumedExistingRun.value, + runnerStatus: runStatus.value?.runner_status, +})) + // 格式化模拟流逝时间(根据轮次和每轮分钟数计算) const formatElapsedTime = (currentRound) => { if (!currentRound || currentRound <= 0) return '0h 0m' @@ -368,18 +468,20 @@ const resetAllState = () => { runStatus.value = {} allActions.value = [] actionIds.value = new Set() + latestActionTimestamp.value = '' prevTwitterRound.value = 0 prevRedditRound.value = 0 startError.value = null isStarting.value = false isStopping.value = false + resumedExistingRun.value = false stopPolling() // 停止之前可能存在的轮询 } // 启动模拟 -const doStartSimulation = async () => { +const doStartSimulation = async ({ force = true } = {}) => { if (!props.simulationId) { - addLog('错误:缺少 simulationId') + addLog(t('step3.missingSimulationId')) return } @@ -388,32 +490,32 @@ const doStartSimulation = async () => { isStarting.value = true startError.value = null - addLog('正在启动双平台并行模拟...') + addLog(t('step3.startingSimulation')) emit('update-status', 'processing') try { const params = { simulation_id: props.simulationId, platform: 'parallel', - force: true, // 强制重新开始 + force, enable_graph_memory_update: true // 开启动态图谱更新 } if (props.maxRounds) { params.max_rounds = props.maxRounds - addLog(`设置最大模拟轮数: ${props.maxRounds}`) + addLog(t('step3.setMaxRounds', { count: props.maxRounds })) } - addLog('已开启动态图谱更新模式') + addLog(t('step3.graphMemoryEnabled')) const res = await startSimulation(params) if (res.success && res.data) { if (res.data.force_restarted) { - addLog('✓ 已清理旧的模拟日志,重新开始模拟') + addLog(`✓ ${t('step3.clearedOldLogs')}`) } - addLog('✓ 模拟引擎启动成功') - addLog(` ├─ PID: ${res.data.process_pid || '-'}`) + addLog(`✓ ${t('step3.simulationStarted')}`) + addLog(formatSimulationPidLog(res.data.process_pid, t)) phase.value = 1 runStatus.value = res.data @@ -421,13 +523,13 @@ const doStartSimulation = async () => { startStatusPolling() startDetailPolling() } else { - startError.value = res.error || '启动失败' - addLog(`✗ 启动失败: ${res.error || '未知错误'}`) + startError.value = res.error || t('step3.startFailed') + addLog(`✗ ${t('step3.startFailedWithMessage', { message: res.error || t('process.unknownError') })}`) emit('update-status', 'error') } } catch (err) { startError.value = err.message - addLog(`✗ 启动异常: ${err.message}`) + addLog(`✗ ${t('step3.startException', { message: err.message })}`) emit('update-status', 'error') } finally { isStarting.value = false @@ -439,21 +541,21 @@ const handleStopSimulation = async () => { if (!props.simulationId) return isStopping.value = true - addLog('正在停止模拟...') + addLog(t('step3.stoppingSimulation')) try { const res = await stopSimulation({ simulation_id: props.simulationId }) if (res.success) { - addLog('✓ 模拟已停止') + addLog(`✓ ${t('step3.simulationStopped')}`) phase.value = 2 stopPolling() emit('update-status', 'completed') } else { - addLog(`停止失败: ${res.error || '未知错误'}`) + addLog(t('step3.stopFailed', { message: res.error || t('process.unknownError') })) } } catch (err) { - addLog(`停止异常: ${err.message}`) + addLog(t('step3.stopException', { message: err.message })) } finally { isStopping.value = false } @@ -486,6 +588,33 @@ const stopPolling = () => { const prevTwitterRound = ref(0) const prevRedditRound = ref(0) +const applyRunStatus = (data) => { + runStatus.value = data + prevTwitterRound.value = data.twitter_current_round || 0 + prevRedditRound.value = data.reddit_current_round || 0 + + if (data.runner_status === 'completed' || data.runner_status === 'stopped') { + phase.value = 2 + emit('update-status', 'completed') + return + } + + if (data.runner_status === 'failed') { + phase.value = 0 + startError.value = data.error || t('process.unknownError') + emit('update-status', 'error') + return + } + + if (data.runner_status === 'running' || data.runner_status === 'starting') { + phase.value = 1 + emit('update-status', 'processing') + return + } + + phase.value = 0 +} + const fetchRunStatus = async () => { if (!props.simulationId) return @@ -494,19 +623,40 @@ const fetchRunStatus = async () => { if (res.success && res.data) { const data = res.data - - runStatus.value = data + const previousTwitterRound = prevTwitterRound.value + const previousRedditRound = prevRedditRound.value + applyRunStatus(data) // 分别检测各平台的轮次变化并输出日志 - if (data.twitter_current_round > prevTwitterRound.value) { - addLog(`[Plaza] R${data.twitter_current_round}/${data.total_rounds} | T:${data.twitter_simulated_hours || 0}h | A:${data.twitter_actions_count}`) + if (data.twitter_current_round > previousTwitterRound) { + addLog(formatSimulationRoundLog({ + platform: 'twitter', + currentRound: data.twitter_current_round, + totalRounds: data.total_rounds, + simulatedHours: data.twitter_simulated_hours, + actionsCount: data.twitter_actions_count, + }, t)) prevTwitterRound.value = data.twitter_current_round } - if (data.reddit_current_round > prevRedditRound.value) { - addLog(`[Community] R${data.reddit_current_round}/${data.total_rounds} | T:${data.reddit_simulated_hours || 0}h | A:${data.reddit_actions_count}`) + if (data.reddit_current_round > previousRedditRound) { + addLog(formatSimulationRoundLog({ + platform: 'reddit', + currentRound: data.reddit_current_round, + totalRounds: data.total_rounds, + simulatedHours: data.reddit_simulated_hours, + actionsCount: data.reddit_actions_count, + }, t)) prevRedditRound.value = data.reddit_current_round } + + if (data.runner_status === 'failed') { + const errorMsg = data.error || t('process.unknownError') + addLog(`✗ ${t('step3.simulationFailed', { message: errorMsg })}`) + stopPolling() + emit('update-status', 'error') + return + } // 检测模拟是否已完成(通过 runner_status 或平台完成状态判断) const isCompleted = data.runner_status === 'completed' || data.runner_status === 'stopped' @@ -517,9 +667,9 @@ const fetchRunStatus = async () => { if (isCompleted || platformsCompleted) { if (platformsCompleted && !isCompleted) { - addLog('✓ 检测到所有平台模拟已结束') + addLog(`✓ ${t('step3.allPlatformsEnded')}`) } - addLog('✓ 模拟已完成') + addLog(`✓ ${t('step3.simulationCompleted')}`) phase.value = 2 stopPolling() emit('update-status', 'completed') @@ -530,6 +680,54 @@ const fetchRunStatus = async () => { } } +const loadExistingRun = async () => { + if (!props.simulationId) return false + + try { + const res = await getRunStatus(props.simulationId) + if (!res.success || !res.data || res.data.runner_status === 'idle') { + return false + } + + resetAllState() + resumedExistingRun.value = true + applyRunStatus(res.data) + await fetchRunStatusDetail() + + if (res.data.runner_status === 'running' || res.data.runner_status === 'starting') { + addLog(t('step3.resumeRunningSimulation')) + startStatusPolling() + startDetailPolling() + return true + } + + if (res.data.runner_status === 'completed') { + addLog(t('step3.resumeCompletedSimulation')) + return true + } + + if (res.data.runner_status === 'stopped') { + addLog(t('step3.resumeStoppedSimulation')) + addLog(t('step3.replayReuseHint')) + return true + } + + if (res.data.runner_status === 'failed') { + addLog(t('step3.resumeFailedSimulation', { message: res.data.error || t('process.unknownError') })) + addLog(t('step3.replayReuseHint')) + return true + } + } catch (err) { + console.warn('加载已有模拟运行状态失败:', err) + } + + return false +} + +const handleRestartSimulation = async () => { + await doStartSimulation({ force: true }) +} + // 检查所有启用的平台是否已完成 const checkPlatformsCompleted = (data) => { // 如果没有任何平台数据,返回 false @@ -558,27 +756,21 @@ const fetchRunStatusDetail = async () => { if (!props.simulationId) return try { - const res = await getRunStatusDetail(props.simulationId) + const params = latestActionTimestamp.value + ? { since: latestActionTimestamp.value } + : { limit: 200 } + const res = await getRunStatusDetail(props.simulationId, params) if (res.success && res.data) { - // 使用 all_actions 获取完整的动作列表 - const serverActions = res.data.all_actions || [] - - // 增量添加新动作(去重) - let newActionsAdded = 0 - serverActions.forEach(action => { - // 生成唯一ID - const actionId = action.id || `${action.timestamp}-${action.platform}-${action.agent_id}-${action.action_type}` - - if (!actionIds.value.has(actionId)) { - actionIds.value.add(actionId) - allActions.value.push({ - ...action, - _uniqueId: actionId - }) - newActionsAdded++ - } + const merged = mergeLiveActions({ + existingActions: allActions.value, + existingIds: actionIds.value, + incomingActions: res.data.all_actions || [], + latestActionTimestamp: latestActionTimestamp.value, }) + allActions.value = merged.actions + actionIds.value = merged.actionIds + latestActionTimestamp.value = merged.latestActionTimestamp // 不自动滚动,让用户自由查看时间轴 // 新动作会在底部追加 @@ -589,22 +781,10 @@ const fetchRunStatusDetail = async () => { } // Helpers -const getActionTypeLabel = (type) => { - const labels = { - 'CREATE_POST': 'POST', - 'REPOST': 'REPOST', - 'LIKE_POST': 'LIKE', - 'CREATE_COMMENT': 'COMMENT', - 'LIKE_COMMENT': 'LIKE', - 'DO_NOTHING': 'IDLE', - 'FOLLOW': 'FOLLOW', - 'SEARCH_POSTS': 'SEARCH', - 'QUOTE_POST': 'QUOTE', - 'UPVOTE_POST': 'UPVOTE', - 'DOWNVOTE_POST': 'DOWNVOTE' - } - return labels[type] || type || 'UNKNOWN' -} +const getPlatformName = (platform) => getTimelinePlatformName(platform, t) +const getPlatformActions = (platform) => getTimelineAvailableActions(platform, t) +const getActionTypeLabel = (type) => getTimelineActionTypeLabel(type, t) +const describeAction = (action) => describeTimelineAction(action, t) const getActionTypeClass = (type) => { const classes = { @@ -632,7 +812,16 @@ const truncateContent = (content, maxLength = 100) => { const formatActionTime = (timestamp) => { if (!timestamp) return '' try { - return new Date(timestamp).toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }) + return new Date(timestamp).toLocaleTimeString(locale.value === 'en' ? 'en-US' : 'zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }) + } catch { + return '' + } +} + +const getReportPreflightBlockMessage = async () => { + try { + const response = await getBackendConfigStatus() + return getReportPreflightBlockReason(response?.data, t) } catch { return '' } @@ -640,17 +829,26 @@ const formatActionTime = (timestamp) => { const handleNextStep = async () => { if (!props.simulationId) { - addLog('错误:缺少 simulationId') + addLog(t('step3.missingSimulationId')) return } if (isGeneratingReport.value) { - addLog('报告生成请求已发送,请稍候...') + addLog(t('step3.reportAlreadyRequested')) return } - + + const preflightBlockMessage = await getReportPreflightBlockMessage() + if (preflightBlockMessage) { + interactionShortcutMessage.value = preflightBlockMessage + addLog(`! ${t('step3.reportPreflightBlocked', { message: preflightBlockMessage })}`) + return + } + + interactionShortcutMessage.value = '' + isGeneratingReport.value = true - addLog('正在启动报告生成...') + addLog(t('step3.reportStarting')) try { const res = await generateReport({ @@ -660,16 +858,30 @@ const handleNextStep = async () => { if (res.success && res.data) { const reportId = res.data.report_id - addLog(`✓ 报告生成任务已启动: ${reportId}`) + addLog(`✓ ${t('step3.reportStarted', { id: reportId })}`) // 跳转到报告页面 router.push({ name: 'Report', params: { reportId } }) } else { - addLog(`✗ 启动报告生成失败: ${res.error || '未知错误'}`) + addLog(`✗ ${t('step3.reportStartFailed', { + message: formatApiError({ + err: { response: { data: res } }, + t, + resolveBaseURL, + locationOrigin: typeof window !== 'undefined' ? window.location.origin : '', + }), + })}`) isGeneratingReport.value = false } } catch (err) { - addLog(`✗ 启动报告生成异常: ${err.message}`) + addLog(`✗ ${t('step3.reportStartException', { + message: formatApiError({ + err, + t, + resolveBaseURL, + locationOrigin: typeof window !== 'undefined' ? window.location.origin : '', + }), + })}`) isGeneratingReport.value = false } } @@ -685,15 +897,36 @@ watch(() => props.systemLogs?.length, () => { }) onMounted(() => { - addLog('Step3 模拟运行初始化') + addLog(t('step3.initLog')) + interactionShortcutMessage.value = '' if (props.simulationId) { - doStartSimulation() + loadExistingRun().then(resumed => { + if (shouldAutoStartSimulation({ replayOnly: props.replayOnly, resumed })) { + doStartSimulation({ force: false }) + return + } + + if (props.replayOnly && !resumed) { + addLog(t('step3.replayOnlyNoRun')) + addLog(t('step3.replayReuseHint')) + } + }) } }) onUnmounted(() => { stopPolling() }) + +const openInteractionShortcut = () => { + const route = buildInteractionRoute({ simulationId: props.simulationId }) + if (!route) { + return + } + + addLog(t('step3.openInteractionShortcut')) + router.push(route) +} </script> <style scoped> @@ -718,6 +951,76 @@ onUnmounted(() => { height: 64px; } +.replay-notice { + display: flex; + gap: 10px; + align-items: flex-start; + padding: 12px 24px; + border-bottom: 1px solid #EAEAEA; + background: linear-gradient(90deg, #FFF7E8 0%, #FFFDF8 100%); + color: #5C3B00; + font-size: 13px; + line-height: 1.5; +} + +.replay-notice-label { + flex: 0 0 auto; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.replay-notice-text { + max-width: 980px; +} + +.interaction-shortcut-notice { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: center; + padding: 14px 24px; + border-bottom: 1px solid #EAEAEA; + background: linear-gradient(90deg, #EEF8FF 0%, #F9FCFF 100%); +} + +.interaction-shortcut-copy { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.interaction-shortcut-label { + color: #0F4C81; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.interaction-shortcut-text { + color: #26445E; + font-size: 13px; + line-height: 1.5; +} + +.interaction-shortcut-btn { + border: 1px solid #9CC7EB; + background: #FFFFFF; + color: #123A5C; + border-radius: 999px; + padding: 10px 16px; + font-size: 13px; + font-weight: 700; + cursor: pointer; + white-space: nowrap; +} + +.interaction-shortcut-btn:hover { + background: #F2F8FD; +} + .status-group { display: flex; gap: 12px; @@ -889,10 +1192,20 @@ onUnmounted(() => { color: #FFF; } +.action-btn.secondary { + background: #FFF; + color: #1A1A1A; + border: 1px solid #D4D4D4; +} + .action-btn.primary:hover:not(:disabled) { background: #333; } +.action-btn.secondary:hover:not(:disabled) { + background: #F7F7F7; +} + .action-btn:disabled { opacity: 0.3; cursor: not-allowed; @@ -1177,6 +1490,8 @@ onUnmounted(() => { font-size: 12px; text-transform: uppercase; letter-spacing: 0.1em; + max-width: min(560px, calc(100vw - 48px)); + text-align: center; } .pulse-ring { @@ -1192,6 +1507,45 @@ onUnmounted(() => { 100% { transform: scale(2.5); opacity: 0; border-color: #EAEAEA; } } +.waiting-diagnostics { + margin-top: 4px; + padding: 14px 16px; + border: 1px solid #EAEAEA; + border-radius: 8px; + background: rgba(255, 255, 255, 0.96); + color: #444; + text-transform: none; + letter-spacing: 0; + font-family: 'JetBrains Mono', monospace; + width: 100%; +} + +.waiting-hint, +.waiting-meta { + margin: 0; + font-size: 11px; + line-height: 1.6; +} + +.waiting-meta { + color: #666; +} + +.waiting-log-tail { + margin: 10px 0 0; + padding: 10px 12px; + background: #111; + color: #F5F5F5; + border-radius: 6px; + text-align: left; + font-size: 11px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + max-height: 180px; + overflow: auto; +} + /* Animation */ .timeline-item-enter-active, .timeline-item-leave-active { @@ -1261,4 +1615,4 @@ onUnmounted(() => { animation: spin 0.8s linear infinite; margin-right: 6px; } -</style> \ No newline at end of file +</style> diff --git a/frontend/src/components/Step4Report.vue b/frontend/src/components/Step4Report.vue index 22f2bdcf..ee1667e8 100644 --- a/frontend/src/components/Step4Report.vue +++ b/frontend/src/components/Step4Report.vue @@ -8,9 +8,61 @@ <!-- Report Header --> <div class="report-header-block"> <div class="report-meta"> - <span class="report-tag">Prediction Report</span> - <span class="report-id">ID: {{ reportId || 'REF-2024-X92' }}</span> + <span class="report-tag">{{ t('step4.reportTag') }}</span> </div> + <div class="report-reference-grid"> + <div class="report-reference-card"> + <div class="report-reference-heading"> + <span class="report-reference-label">{{ t('step4.reportIdLabel') }}</span> + <div class="report-reference-actions"> + <button + class="report-reference-copy" + :disabled="!reportId" + type="button" + @click="copyReference('report', reportId)" + > + {{ copiedReferenceKey === 'report' ? t('step4.copied') : t('step4.copyId') }} + </button> + <button + class="report-reference-copy" + :disabled="!reportId" + type="button" + @click="downloadReportMarkdown" + > + {{ t('step4.exportMd') }} + </button> + </div> + </div> + <span class="report-reference-value">{{ resolvedReportReference }}</span> + </div> + <div class="report-reference-card"> + <div class="report-reference-heading"> + <span class="report-reference-label">{{ t('step4.simulationIdLabel') }}</span> + <button + class="report-reference-copy" + :disabled="!simulationId" + type="button" + @click="copyReference('simulation', simulationId)" + > + {{ copiedReferenceKey === 'simulation' ? t('step4.copied') : t('step4.copyId') }} + </button> + </div> + <span class="report-reference-value">{{ simulationId || t('step4.unavailableId') }}</span> + </div> + </div> + <div class="report-reference-bundle-row"> + <button + class="report-reference-copy" + :disabled="!verificationBundle" + type="button" + @click="copyReference('bundle', verificationBundle)" + > + {{ copiedReferenceKey === 'bundle' ? t('step4.copied') : t('step4.copyBundle') }} + </button> + </div> + <p class="report-reference-hint"> + {{ t('step4.referenceHint') }} + </p> <h1 class="main-title">{{ reportOutline.title }}</h1> <p class="sub-title">{{ reportOutline.summary }}</p> <div class="header-divider"></div> @@ -58,21 +110,42 @@ <path d="M12 2a10 10 0 0 1 10 10" stroke-width="4" stroke="#4B5563" stroke-linecap="round"></path> </svg> </div> - <span class="loading-text">正在生成{{ section.title }}...</span> + <span class="loading-text">{{ t('step4.sectionGenerating', { title: section.title }) }}</span> </div> </div> </div> </div> </div> + <!-- Failed State --> + <div v-else-if="isFailed" class="failed-placeholder"> + <div class="failed-icon"> + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> + <circle cx="12" cy="12" r="10"></circle> + <line x1="12" y1="8" x2="12" y2="13"></line> + <circle cx="12" cy="16" r="1"></circle> + </svg> + </div> + <h2 class="failed-title">{{ t('step4.failedTitle') }}</h2> + <p class="failed-text">{{ failureMessage }}</p> + <div class="failed-actions"> + <button class="retry-report-btn" :disabled="isRetrying || !simulationId" @click="retryReportGeneration"> + <span>{{ isRetrying ? t('step4.retrying') : t('step4.retryReport') }}</span> + </button> + <button class="retry-report-btn secondary" :disabled="!simulationId" @click="goToInteraction"> + <span>{{ t('step4.goToInteractionDirect') }}</span> + </button> + </div> + </div> + <!-- Waiting State --> - <div v-if="!reportOutline" class="waiting-placeholder"> + <div v-else class="waiting-placeholder"> <div class="waiting-animation"> <div class="waiting-ring"></div> <div class="waiting-ring"></div> <div class="waiting-ring"></div> </div> - <span class="waiting-text">Waiting for Report Agent...</span> + <span class="waiting-text">{{ t('step4.waitingForAgent') }}</span> </div> </div> @@ -86,18 +159,18 @@ </div> <!-- Workflow Overview (flat, status-based palette) --> - <div class="workflow-overview" v-if="agentLogs.length > 0 || reportOutline"> + <div class="workflow-overview" v-if="agentLogs.length > 0 || reportOutline || isFailed"> <div class="workflow-metrics"> <div class="metric"> - <span class="metric-label">Sections</span> + <span class="metric-label">{{ t('step4.metrics.sections') }}</span> <span class="metric-value mono">{{ completedSections }}/{{ totalSections }}</span> </div> <div class="metric"> - <span class="metric-label">Elapsed</span> + <span class="metric-label">{{ t('step4.metrics.elapsed') }}</span> <span class="metric-value mono">{{ formatElapsedTime }}</span> </div> <div class="metric"> - <span class="metric-label">Tools</span> + <span class="metric-label">{{ t('step4.metrics.tools') }}</span> <span class="metric-value mono">{{ totalToolCalls }}</span> </div> <div class="metric metric-right"> @@ -105,6 +178,21 @@ </div> </div> + <div v-if="isFailed" class="failure-banner"> + <div class="failure-banner-copy"> + <span class="failure-banner-title">{{ t('step4.generationStopped') }}</span> + <span class="failure-banner-text">{{ failureMessage }}</span> + </div> + <div class="failure-banner-actions"> + <button class="failure-banner-btn" :disabled="isRetrying || !simulationId" @click="retryReportGeneration"> + {{ isRetrying ? t('step4.retrying') : t('step4.retryShort') }} + </button> + <button class="failure-banner-btn secondary" :disabled="!simulationId" @click="goToInteraction"> + {{ t('step4.interactionShort') }} + </button> + </div> + </div> + <div class="workflow-steps" v-if="workflowSteps.length > 0"> <div v-for="(step, sidx) in workflowSteps" @@ -129,7 +217,7 @@ <!-- Next Step Button - 在完成后显示 --> <button v-if="isComplete" class="next-step-btn" @click="goToInteraction"> - <span>进入深度互动</span> + <span>{{ t('step4.goToInteraction') }}</span> <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"> <line x1="5" y1="12" x2="19" y2="12"></line> <polyline points="12 5 19 12 12 19"></polyline> @@ -166,11 +254,11 @@ <!-- Report Start --> <template v-if="log.action === 'report_start'"> <div class="info-row"> - <span class="info-key">Simulation</span> + <span class="info-key">{{ t('step4.simulation') }}</span> <span class="info-val mono">{{ log.details?.simulation_id }}</span> </div> <div class="info-row" v-if="log.details?.simulation_requirement"> - <span class="info-key">Requirement</span> + <span class="info-key">{{ t('step4.requirement') }}</span> <span class="info-val">{{ log.details.simulation_requirement }}</span> </div> </template> @@ -182,7 +270,7 @@ <template v-if="log.action === 'planning_complete'"> <div class="status-message success">{{ log.details?.message }}</div> <div class="outline-badge" v-if="log.details?.outline"> - {{ log.details.outline.sections?.length || 0 }} sections planned + {{ t('step4.sectionsPlanned', { count: log.details.outline.sections?.length || 0 }) }} </div> </template> @@ -309,10 +397,10 @@ <div class="llm-meta"> <span class="meta-tag">Iteration {{ log.details?.iteration }}</span> <span class="meta-tag" :class="{ active: log.details?.has_tool_calls }"> - Tools: {{ log.details?.has_tool_calls ? 'Yes' : 'No' }} + {{ t('step4.toolsLabel') }}: {{ log.details?.has_tool_calls ? t('step4.yes') : t('step4.no') }} </span> <span class="meta-tag" :class="{ active: log.details?.has_final_answer, 'final-answer': log.details?.has_final_answer }"> - Final: {{ log.details?.has_final_answer ? 'Yes' : 'No' }} + {{ t('step4.finalLabel') }}: {{ log.details?.has_final_answer ? t('step4.yes') : t('step4.no') }} </span> </div> <!-- 当是最终答案时,显示特殊提示 --> @@ -320,7 +408,7 @@ <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"> <polyline points="20 6 9 17 4 12"></polyline> </svg> - <span>Section "{{ log.section_title }}" content generated</span> + <span>{{ t('step4.sectionContentGenerated', { title: log.section_title }) }}</span> </div> <div v-if="expandedLogs.has(log.timestamp) && log.details?.response" class="llm-content"> <pre>{{ log.details.response }}</pre> @@ -334,7 +422,7 @@ <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path> <polyline points="22 4 12 14.01 9 11.01"></polyline> </svg> - <span>Report Generation Complete</span> + <span>{{ t('step4.reportGenerationComplete') }}</span> </div> </template> </div> @@ -347,17 +435,17 @@ <div class="footer-actions"> <!-- Tool Call: Show/Hide Params --> <button v-if="log.action === 'tool_call' && log.details?.parameters" class="action-btn" @click.stop="toggleLogExpand(log)"> - {{ expandedLogs.has(log.timestamp) ? 'Hide Params' : 'Show Params' }} + {{ expandedLogs.has(log.timestamp) ? t('step4.hideParams') : t('step4.showParams') }} </button> <!-- Tool Result: Raw/Structured View --> <button v-if="log.action === 'tool_result'" class="action-btn" @click.stop="toggleRawResult(log.timestamp, $event)"> - {{ showRawResult[log.timestamp] ? 'Structured View' : 'Raw Output' }} + {{ showRawResult[log.timestamp] ? t('step4.structuredView') : t('step4.rawOutput') }} </button> <!-- LLM Response: Show/Hide Response --> <button v-if="log.action === 'llm_response' && log.details?.response" class="action-btn" @click.stop="toggleLogExpand(log)"> - {{ expandedLogs.has(log.timestamp) ? 'Hide Response' : 'Show Response' }} + {{ expandedLogs.has(log.timestamp) ? t('step4.hideResponse') : t('step4.showResponse') }} </button> </div> </div> @@ -366,9 +454,9 @@ </TransitionGroup> <!-- Empty State --> - <div v-if="agentLogs.length === 0 && !isComplete" class="workflow-empty"> + <div v-if="agentLogs.length === 0 && !isComplete && !isFailed" class="workflow-empty"> <div class="empty-pulse"></div> - <span>Waiting for agent activity...</span> + <span>{{ t('step4.waitingForActivity') }}</span> </div> </div> </div> @@ -377,7 +465,7 @@ <!-- Bottom Console Logs --> <div class="console-logs"> <div class="log-header"> - <span class="log-title">CONSOLE OUTPUT</span> + <span class="log-title">{{ t('step4.consoleOutput') }}</span> <span class="log-id">{{ reportId || 'NO_REPORT' }}</span> </div> <div class="log-content" ref="logContent"> @@ -392,9 +480,26 @@ <script setup> import { ref, computed, watch, onMounted, onUnmounted, nextTick, h, reactive } from 'vue' import { useRouter } from 'vue-router' -import { getAgentLog, getConsoleLog } from '../api/report' +import { useI18n } from 'vue-i18n' +import { generateReport, getAgentLog, getConsoleLog, getReport } from '../api/report' +import { resolveBaseURL } from '../api/index.js' +import { copyText } from '../utils/clipboard' +import { triggerHistoryReportDownload } from './historyReportDownload.js' +import { buildInteractionRoute } from './interactionRoute.js' +import { resolveReportReferenceValue } from './reportReferences.js' +import { buildVerificationReferenceBundle } from './verificationBundle.js' +import { + extractFinalContent, + getInterviewAnswerForQuestion, + isMissingPlatformReply, + parseInterview, + parseInsightForge, + parsePanorama, + parseQuickSearch, +} from './reportParsers.js' const router = useRouter() +const { t, locale } = useI18n() const props = defineProps({ reportId: String, @@ -404,10 +509,79 @@ const props = defineProps({ const emit = defineEmits(['add-log', 'update-status']) +const resolvedReportReference = computed(() => + resolveReportReferenceValue(props.reportId, t('step4.unavailableId')) +) +const reportTimestamp = ref('') +const verificationBundle = computed(() => + buildVerificationReferenceBundle({ + simulationId: props.simulationId, + reportId: props.reportId, + timestamp: reportTimestamp.value, + }) +) + // Navigation const goToInteraction = () => { - if (props.reportId) { - router.push({ name: 'Interaction', params: { reportId: props.reportId } }) + const route = buildInteractionRoute({ + reportId: props.reportId, + simulationId: props.simulationId, + }) + if (route) { + router.push(route) + } +} + +const syncReportState = async () => { + if (!props.reportId) return + + try { + const res = await getReport(props.reportId) + if (!res.success || !res.data) return + + reportStatus.value = res.data.status || null + reportError.value = res.data.error || '' + reportTimestamp.value = res.data.completed_at || res.data.created_at || '' + + if (reportStatus.value === 'completed') { + isComplete.value = true + emit('update-status', 'completed') + stopPolling() + return + } + + if (reportStatus.value === 'failed') { + currentSectionIndex.value = null + emit('update-status', 'error') + stopPolling() + } + } catch (err) { + console.warn('Failed to fetch report state:', err) + } +} + +const retryReportGeneration = async () => { + if (!props.simulationId || isRetrying.value) return + + isRetrying.value = true + addLog(t('step4.retryLog', { id: props.simulationId })) + + try { + const res = await generateReport({ + simulation_id: props.simulationId, + force_regenerate: true + }) + + if (res.success && res.data?.report_id) { + router.push({ name: 'Report', params: { reportId: res.data.report_id } }) + return + } + + addLog(t('step4.retryFailed', { message: res.error || t('process.unknownError') })) + } catch (err) { + addLog(t('step4.retryException', { message: err.message })) + } finally { + isRetrying.value = false } } @@ -423,11 +597,48 @@ const expandedContent = ref(new Set()) const expandedLogs = ref(new Set()) const collapsedSections = ref(new Set()) const isComplete = ref(false) +const reportStatus = ref(null) +const reportError = ref('') +const isRetrying = ref(false) const startTime = ref(null) const leftPanel = ref(null) const rightPanel = ref(null) const logContent = ref(null) const showRawResult = reactive({}) +const copiedReferenceKey = ref('') +let copiedReferenceTimer = null + +const clearCopiedReferenceTimer = () => { + if (copiedReferenceTimer) { + window.clearTimeout(copiedReferenceTimer) + copiedReferenceTimer = null + } +} + +const copyReference = async (key, value) => { + const copied = await copyText(value) + if (!copied) { + return + } + + copiedReferenceKey.value = key + clearCopiedReferenceTimer() + copiedReferenceTimer = window.setTimeout(() => { + copiedReferenceKey.value = '' + copiedReferenceTimer = null + }, 2000) +} + +const downloadReportMarkdown = () => { + if (!props.reportId) { + return + } + + triggerHistoryReportDownload(props.reportId, { + simulationId: props.simulationId, + baseURL: resolveBaseURL(), + }) +} // Toggle functions const toggleRawResult = (timestamp, event) => { @@ -495,39 +706,40 @@ const isLogCollapsed = (log) => { // Tool configurations with display names and colors const toolConfig = { 'insight_forge': { - name: 'Deep Insight', + nameKey: 'step4.toolNames.insightForge', color: 'purple', icon: 'lightbulb' // 灯泡图标 - 代表洞察 }, 'panorama_search': { - name: 'Panorama Search', + nameKey: 'step4.toolNames.panoramaSearch', color: 'blue', icon: 'globe' // 地球图标 - 代表全景搜索 }, 'interview_agents': { - name: 'Agent Interview', + nameKey: 'step4.toolNames.interviewAgents', color: 'green', icon: 'users' // 用户图标 - 代表对话 }, 'quick_search': { - name: 'Quick Search', + nameKey: 'step4.toolNames.quickSearch', color: 'orange', icon: 'zap' // 闪电图标 - 代表快速 }, 'get_graph_statistics': { - name: 'Graph Stats', + nameKey: 'step4.toolNames.graphStats', color: 'cyan', icon: 'chart' // 图表图标 - 代表统计 }, 'get_entities_by_type': { - name: 'Entity Query', + nameKey: 'step4.toolNames.entityQuery', color: 'pink', icon: 'database' // 数据库图标 - 代表实体 } } const getToolDisplayName = (toolName) => { - return toolConfig[toolName]?.name || toolName + const config = toolConfig[toolName] + return config?.nameKey ? t(config.nameKey) : toolName } const getToolColor = (toolName) => { @@ -538,422 +750,14 @@ const getToolIcon = (toolName) => { return toolConfig[toolName]?.icon || 'tool' } -// Parse functions -const parseInsightForge = (text) => { - const result = { - query: '', - simulationRequirement: '', - stats: { facts: 0, entities: 0, relationships: 0 }, - subQueries: [], - facts: [], - entities: [], - relations: [] +const formatExpandLabel = (expanded, count, unitKey) => { + if (expanded) { + return t('step4.toolDisplay.showLess') } - - try { - // 提取分析问题 - const queryMatch = text.match(/分析问题:\s*(.+?)(?:\n|$)/) - if (queryMatch) result.query = queryMatch[1].trim() - - // 提取预测场景 - const reqMatch = text.match(/预测场景:\s*(.+?)(?:\n|$)/) - if (reqMatch) result.simulationRequirement = reqMatch[1].trim() - - // 提取统计数据 - 匹配"相关预测事实: X条"格式 - const factMatch = text.match(/相关预测事实:\s*(\d+)/) - const entityMatch = text.match(/涉及实体:\s*(\d+)/) - const relMatch = text.match(/关系链:\s*(\d+)/) - if (factMatch) result.stats.facts = parseInt(factMatch[1]) - if (entityMatch) result.stats.entities = parseInt(entityMatch[1]) - if (relMatch) result.stats.relationships = parseInt(relMatch[1]) - - // 提取子问题 - 完整提取,不限制数量 - const subQSection = text.match(/### 分析的子问题\n([\s\S]*?)(?=\n###|$)/) - if (subQSection) { - const lines = subQSection[1].split('\n').filter(l => l.match(/^\d+\./)) - result.subQueries = lines.map(l => l.replace(/^\d+\.\s*/, '').trim()).filter(Boolean) - } - - // 提取关键事实 - 完整提取,不限制数量 - const factsSection = text.match(/### 【关键事实】[\s\S]*?\n([\s\S]*?)(?=\n###|$)/) - if (factsSection) { - const lines = factsSection[1].split('\n').filter(l => l.match(/^\d+\./)) - result.facts = lines.map(l => { - const match = l.match(/^\d+\.\s*"?(.+?)"?\s*$/) - return match ? match[1].replace(/^"|"$/g, '').trim() : l.replace(/^\d+\.\s*/, '').trim() - }).filter(Boolean) - } - - // 提取核心实体 - 完整提取,包含摘要和相关事实数 - const entitySection = text.match(/### 【核心实体】\n([\s\S]*?)(?=\n###|$)/) - if (entitySection) { - const entityText = entitySection[1] - // 按 "- **" 分割实体块 - const entityBlocks = entityText.split(/\n(?=- \*\*)/).filter(b => b.trim().startsWith('- **')) - result.entities = entityBlocks.map(block => { - const nameMatch = block.match(/^-\s*\*\*(.+?)\*\*\s*\((.+?)\)/) - const summaryMatch = block.match(/摘要:\s*"?(.+?)"?(?:\n|$)/) - const relatedMatch = block.match(/相关事实:\s*(\d+)/) - return { - name: nameMatch ? nameMatch[1].trim() : '', - type: nameMatch ? nameMatch[2].trim() : '', - summary: summaryMatch ? summaryMatch[1].trim() : '', - relatedFactsCount: relatedMatch ? parseInt(relatedMatch[1]) : 0 - } - }).filter(e => e.name) - } - - // 提取关系链 - 完整提取,不限制数量 - const relSection = text.match(/### 【关系链】\n([\s\S]*?)(?=\n###|$)/) - if (relSection) { - const lines = relSection[1].split('\n').filter(l => l.trim().startsWith('-')) - result.relations = lines.map(l => { - const match = l.match(/^-\s*(.+?)\s*--\[(.+?)\]-->\s*(.+)$/) - if (match) { - return { source: match[1].trim(), relation: match[2].trim(), target: match[3].trim() } - } - return null - }).filter(Boolean) - } - } catch (e) { - console.warn('Parse insight_forge failed:', e) - } - - return result -} - -const parsePanorama = (text) => { - const result = { - query: '', - stats: { nodes: 0, edges: 0, activeFacts: 0, historicalFacts: 0 }, - activeFacts: [], - historicalFacts: [], - entities: [] - } - - try { - // 提取查询 - const queryMatch = text.match(/查询:\s*(.+?)(?:\n|$)/) - if (queryMatch) result.query = queryMatch[1].trim() - - // 提取统计数据 - const nodesMatch = text.match(/总节点数:\s*(\d+)/) - const edgesMatch = text.match(/总边数:\s*(\d+)/) - const activeMatch = text.match(/当前有效事实:\s*(\d+)/) - const histMatch = text.match(/历史\/过期事实:\s*(\d+)/) - if (nodesMatch) result.stats.nodes = parseInt(nodesMatch[1]) - if (edgesMatch) result.stats.edges = parseInt(edgesMatch[1]) - if (activeMatch) result.stats.activeFacts = parseInt(activeMatch[1]) - if (histMatch) result.stats.historicalFacts = parseInt(histMatch[1]) - - // 提取当前有效事实 - 完整提取,不限制数量 - const activeSection = text.match(/### 【当前有效事实】[\s\S]*?\n([\s\S]*?)(?=\n###|$)/) - if (activeSection) { - const lines = activeSection[1].split('\n').filter(l => l.match(/^\d+\./)) - result.activeFacts = lines.map(l => { - // 移除编号和引号 - const factText = l.replace(/^\d+\.\s*/, '').replace(/^"|"$/g, '').trim() - return factText - }).filter(Boolean) - } - - // 提取历史/过期事实 - 完整提取,不限制数量 - const histSection = text.match(/### 【历史\/过期事实】[\s\S]*?\n([\s\S]*?)(?=\n###|$)/) - if (histSection) { - const lines = histSection[1].split('\n').filter(l => l.match(/^\d+\./)) - result.historicalFacts = lines.map(l => { - const factText = l.replace(/^\d+\.\s*/, '').replace(/^"|"$/g, '').trim() - return factText - }).filter(Boolean) - } - - // 提取涉及实体 - 完整提取,不限制数量 - const entitySection = text.match(/### 【涉及实体】\n([\s\S]*?)(?=\n###|$)/) - if (entitySection) { - const lines = entitySection[1].split('\n').filter(l => l.trim().startsWith('-')) - result.entities = lines.map(l => { - const match = l.match(/^-\s*\*\*(.+?)\*\*\s*\((.+?)\)/) - if (match) return { name: match[1].trim(), type: match[2].trim() } - return null - }).filter(Boolean) - } - } catch (e) { - console.warn('Parse panorama failed:', e) - } - - return result -} - -const parseInterview = (text) => { - const result = { - topic: '', - agentCount: '', - successCount: 0, - totalCount: 0, - selectionReason: '', - interviews: [], - summary: '' - } - - try { - // 提取采访主题 - const topicMatch = text.match(/\*\*采访主题:\*\*\s*(.+?)(?:\n|$)/) - if (topicMatch) result.topic = topicMatch[1].trim() - - // 提取采访人数(如 "5 / 9 位模拟Agent") - const countMatch = text.match(/\*\*采访人数:\*\*\s*(\d+)\s*\/\s*(\d+)/) - if (countMatch) { - result.successCount = parseInt(countMatch[1]) - result.totalCount = parseInt(countMatch[2]) - result.agentCount = `${countMatch[1]} / ${countMatch[2]}` - } - - // 提取采访对象选择理由 - const reasonMatch = text.match(/### 采访对象选择理由\n([\s\S]*?)(?=\n---\n|\n### 采访实录)/) - if (reasonMatch) { - result.selectionReason = reasonMatch[1].trim() - } - - // 解析每个人的选择理由 - const parseIndividualReasons = (reasonText) => { - const reasons = {} - if (!reasonText) return reasons - - const lines = reasonText.split(/\n+/) - let currentName = null - let currentReason = [] - - for (const line of lines) { - let headerMatch = null - let name = null - let reasonStart = null - - // 格式1: 数字. **名字(index=X)**:理由 - // 例如: 1. **校友_345(index=1)**:作为武大校友... - headerMatch = line.match(/^\d+\.\s*\*\*([^*((]+)(?:[((]index\s*=?\s*\d+[))])?\*\*[::]\s*(.*)/) - if (headerMatch) { - name = headerMatch[1].trim() - reasonStart = headerMatch[2] - } - - // 格式2: - 选择名字(index X):理由 - // 例如: - 选择家长_601(index 0):作为家长群体代表... - if (!headerMatch) { - headerMatch = line.match(/^-\s*选择([^((]+)(?:[((]index\s*=?\s*\d+[))])?[::]\s*(.*)/) - if (headerMatch) { - name = headerMatch[1].trim() - reasonStart = headerMatch[2] - } - } - - // 格式3: - **名字(index X)**:理由 - // 例如: - **家长_601(index 0)**:作为家长群体代表... - if (!headerMatch) { - headerMatch = line.match(/^-\s*\*\*([^*((]+)(?:[((]index\s*=?\s*\d+[))])?\*\*[::]\s*(.*)/) - if (headerMatch) { - name = headerMatch[1].trim() - reasonStart = headerMatch[2] - } - } - - if (name) { - // 保存上一个人的理由 - if (currentName && currentReason.length > 0) { - reasons[currentName] = currentReason.join(' ').trim() - } - // 开始新的人 - currentName = name - currentReason = reasonStart ? [reasonStart.trim()] : [] - } else if (currentName && line.trim() && !line.match(/^未选|^综上|^最终选择/)) { - // 理由的续行(排除结尾总结段落) - currentReason.push(line.trim()) - } - } - - // 保存最后一个人的理由 - if (currentName && currentReason.length > 0) { - reasons[currentName] = currentReason.join(' ').trim() - } - - return reasons - } - - const individualReasons = parseIndividualReasons(result.selectionReason) - - // 提取每个采访记录 - const interviewBlocks = text.split(/#### 采访 #\d+:/).slice(1) - - interviewBlocks.forEach((block, index) => { - const interview = { - num: index + 1, - title: '', - name: '', - role: '', - bio: '', - selectionReason: '', - questions: [], - twitterAnswer: '', - redditAnswer: '', - quotes: [] - } - - // 提取标题(如 "学生"、"教育从业者" 等) - const titleMatch = block.match(/^(.+?)\n/) - if (titleMatch) interview.title = titleMatch[1].trim() - - // 提取姓名和角色 - const nameRoleMatch = block.match(/\*\*(.+?)\*\*\s*\((.+?)\)/) - if (nameRoleMatch) { - interview.name = nameRoleMatch[1].trim() - interview.role = nameRoleMatch[2].trim() - // 设置该人的选择理由 - interview.selectionReason = individualReasons[interview.name] || '' - } - - // 提取简介 - const bioMatch = block.match(/_简介:\s*([\s\S]*?)_\n/) - if (bioMatch) { - interview.bio = bioMatch[1].trim().replace(/\.\.\.$/, '...') - } - - // 提取问题列表 - const qMatch = block.match(/\*\*Q:\*\*\s*([\s\S]*?)(?=\n\n\*\*A:\*\*|\*\*A:\*\*)/) - if (qMatch) { - const qText = qMatch[1].trim() - // 按数字编号分割问题 - const questions = qText.split(/\n\d+\.\s+/).filter(q => q.trim()) - if (questions.length > 0) { - // 如果第一个问题前面有"1.",需要特殊处理 - const firstQ = qText.match(/^1\.\s+(.+)/) - if (firstQ) { - interview.questions = [firstQ[1].trim(), ...questions.slice(1).map(q => q.trim())] - } else { - interview.questions = questions.map(q => q.trim()) - } - } - } - - // 提取回答 - 分Twitter和Reddit - const answerMatch = block.match(/\*\*A:\*\*\s*([\s\S]*?)(?=\*\*关键引言|$)/) - if (answerMatch) { - const answerText = answerMatch[1].trim() - - // 分离Twitter和Reddit回答 - const twitterMatch = answerText.match(/【Twitter平台回答】\n?([\s\S]*?)(?=【Reddit平台回答】|$)/) - const redditMatch = answerText.match(/【Reddit平台回答】\n?([\s\S]*?)$/) - - if (twitterMatch) { - interview.twitterAnswer = twitterMatch[1].trim() - } - if (redditMatch) { - interview.redditAnswer = redditMatch[1].trim() - } - - // 平台回退逻辑(兼容旧格式:只有一个平台标记的情况) - if (!twitterMatch && redditMatch) { - // 只有 Reddit 回答,仅在非占位文本时复制为默认显示 - if (interview.redditAnswer && interview.redditAnswer !== '(该平台未获得回复)') { - interview.twitterAnswer = interview.redditAnswer - } - } else if (twitterMatch && !redditMatch) { - if (interview.twitterAnswer && interview.twitterAnswer !== '(该平台未获得回复)') { - interview.redditAnswer = interview.twitterAnswer - } - } else if (!twitterMatch && !redditMatch) { - // 没有分平台标记(极旧格式),整体作为回答 - interview.twitterAnswer = answerText - } - } - - // 提取关键引言(兼容多种引号格式) - const quotesMatch = block.match(/\*\*关键引言:\*\*\n([\s\S]*?)(?=\n---|\n####|$)/) - if (quotesMatch) { - const quotesText = quotesMatch[1] - // 优先匹配 > "text" 格式 - let quoteMatches = quotesText.match(/> "([^"]+)"/g) - // 回退:匹配 > "text" 或 > \u201Ctext\u201D(中文引号) - if (!quoteMatches) { - quoteMatches = quotesText.match(/> [\u201C""]([^\u201D""]+)[\u201D""]/g) - } - if (quoteMatches) { - interview.quotes = quoteMatches - .map(q => q.replace(/^> [\u201C""]|[\u201D""]$/g, '').trim()) - .filter(q => q) - } - } - - if (interview.name || interview.title) { - result.interviews.push(interview) - } - }) - - // 提取采访摘要 - const summaryMatch = text.match(/### 采访摘要与核心观点\n([\s\S]*?)$/) - if (summaryMatch) { - result.summary = summaryMatch[1].trim() - } - } catch (e) { - console.warn('Parse interview failed:', e) - } - - return result -} - -const parseQuickSearch = (text) => { - const result = { - query: '', - count: 0, - facts: [], - edges: [], - nodes: [] - } - - try { - // 提取搜索查询 - const queryMatch = text.match(/搜索查询:\s*(.+?)(?:\n|$)/) - if (queryMatch) result.query = queryMatch[1].trim() - - // 提取结果数量 - const countMatch = text.match(/找到\s*(\d+)\s*条/) - if (countMatch) result.count = parseInt(countMatch[1]) - - // 提取相关事实 - 完整提取,不限制数量 - const factsSection = text.match(/### 相关事实:\n([\s\S]*)$/) - if (factsSection) { - const lines = factsSection[1].split('\n').filter(l => l.match(/^\d+\./)) - result.facts = lines.map(l => l.replace(/^\d+\.\s*/, '').trim()).filter(Boolean) - } - - // 尝试提取边信息(如果有) - const edgesSection = text.match(/### 相关边:\n([\s\S]*?)(?=\n###|$)/) - if (edgesSection) { - const lines = edgesSection[1].split('\n').filter(l => l.trim().startsWith('-')) - result.edges = lines.map(l => { - const match = l.match(/^-\s*(.+?)\s*--\[(.+?)\]-->\s*(.+)$/) - if (match) { - return { source: match[1].trim(), relation: match[2].trim(), target: match[3].trim() } - } - return null - }).filter(Boolean) - } - - // 尝试提取节点信息(如果有) - const nodesSection = text.match(/### 相关节点:\n([\s\S]*?)(?=\n###|$)/) - if (nodesSection) { - const lines = nodesSection[1].split('\n').filter(l => l.trim().startsWith('-')) - result.nodes = lines.map(l => { - const match = l.match(/^-\s*\*\*(.+?)\*\*\s*\((.+?)\)/) - if (match) return { name: match[1].trim(), type: match[2].trim() } - const simpleMatch = l.match(/^-\s*(.+)$/) - if (simpleMatch) return { name: simpleMatch[1].trim(), type: '' } - return null - }).filter(Boolean) - } - } catch (e) { - console.warn('Parse quick_search failed:', e) - } - - return result + return t('step4.toolDisplay.showAll', { + count, + unit: t(unitKey) + }) } // ========== Sub Components ========== @@ -972,30 +776,30 @@ const InsightDisplay = { const formatSize = (length) => { if (!length) return '' if (length >= 1000) { - return `${(length / 1000).toFixed(1)}k chars` + return t('step4.toolDisplay.charCountCompact', { count: (length / 1000).toFixed(1) }) } - return `${length} chars` + return t('step4.toolDisplay.charCount', { count: length }) } return () => h('div', { class: 'insight-display' }, [ // Header Section - like interview header h('div', { class: 'insight-header' }, [ h('div', { class: 'header-main' }, [ - h('div', { class: 'header-title' }, 'Deep Insight'), + h('div', { class: 'header-title' }, t('step4.toolNames.insightForge')), h('div', { class: 'header-stats' }, [ h('span', { class: 'stat-item' }, [ h('span', { class: 'stat-value' }, props.result.stats.facts || props.result.facts.length), - h('span', { class: 'stat-label' }, 'Facts') + h('span', { class: 'stat-label' }, t('step4.toolDisplay.labels.facts')) ]), h('span', { class: 'stat-divider' }, '/'), h('span', { class: 'stat-item' }, [ h('span', { class: 'stat-value' }, props.result.stats.entities || props.result.entities.length), - h('span', { class: 'stat-label' }, 'Entities') + h('span', { class: 'stat-label' }, t('step4.toolDisplay.labels.entities')) ]), h('span', { class: 'stat-divider' }, '/'), h('span', { class: 'stat-item' }, [ h('span', { class: 'stat-value' }, props.result.stats.relationships || props.result.relations.length), - h('span', { class: 'stat-label' }, 'Relations') + h('span', { class: 'stat-label' }, t('step4.toolDisplay.labels.relations')) ]), props.resultLength && h('span', { class: 'stat-divider' }, '·'), props.resultLength && h('span', { class: 'stat-size' }, formatSize(props.resultLength)) @@ -1003,7 +807,7 @@ const InsightDisplay = { ]), props.result.query && h('div', { class: 'header-topic' }, props.result.query), props.result.simulationRequirement && h('div', { class: 'header-scenario' }, [ - h('span', { class: 'scenario-label' }, '预测场景: '), + h('span', { class: 'scenario-label' }, t('step4.toolDisplay.insight.scenarioLabel')), h('span', { class: 'scenario-text' }, props.result.simulationRequirement) ]) ]), @@ -1014,25 +818,25 @@ const InsightDisplay = { class: ['insight-tab', { active: activeTab.value === 'facts' }], onClick: () => { activeTab.value = 'facts' } }, [ - h('span', { class: 'tab-label' }, `当前关键记忆 (${props.result.facts.length})`) + h('span', { class: 'tab-label' }, t('step4.toolDisplay.insight.tabs.facts', { count: props.result.facts.length })) ]), h('button', { class: ['insight-tab', { active: activeTab.value === 'entities' }], onClick: () => { activeTab.value = 'entities' } }, [ - h('span', { class: 'tab-label' }, `核心实体 (${props.result.entities.length})`) + h('span', { class: 'tab-label' }, t('step4.toolDisplay.insight.tabs.entities', { count: props.result.entities.length })) ]), h('button', { class: ['insight-tab', { active: activeTab.value === 'relations' }], onClick: () => { activeTab.value = 'relations' } }, [ - h('span', { class: 'tab-label' }, `关系链 (${props.result.relations.length})`) + h('span', { class: 'tab-label' }, t('step4.toolDisplay.insight.tabs.relations', { count: props.result.relations.length })) ]), props.result.subQueries.length > 0 && h('button', { class: ['insight-tab', { active: activeTab.value === 'subqueries' }], onClick: () => { activeTab.value = 'subqueries' } }, [ - h('span', { class: 'tab-label' }, `子问题 (${props.result.subQueries.length})`) + h('span', { class: 'tab-label' }, t('step4.toolDisplay.insight.tabs.subqueries', { count: props.result.subQueries.length })) ]) ]), @@ -1041,8 +845,8 @@ const InsightDisplay = { // Facts Tab activeTab.value === 'facts' && props.result.facts.length > 0 && h('div', { class: 'facts-panel' }, [ h('div', { class: 'panel-header' }, [ - h('span', { class: 'panel-title' }, '时序记忆中所关联的最新关键事实'), - h('span', { class: 'panel-count' }, `共 ${props.result.facts.length} 条`) + h('span', { class: 'panel-title' }, t('step4.toolDisplay.insight.panels.facts')), + h('span', { class: 'panel-count' }, t('step4.toolDisplay.countEntries', { count: props.result.facts.length })) ]), h('div', { class: 'facts-list' }, (expandedFacts.value ? props.result.facts : props.result.facts.slice(0, INITIAL_SHOW_COUNT)).map((fact, i) => @@ -1055,35 +859,35 @@ const InsightDisplay = { props.result.facts.length > INITIAL_SHOW_COUNT && h('button', { class: 'expand-btn', onClick: () => { expandedFacts.value = !expandedFacts.value } - }, expandedFacts.value ? `收起 ▲` : `展开全部 ${props.result.facts.length} 条 ▼`) + }, formatExpandLabel(expandedFacts.value, props.result.facts.length, 'step4.toolDisplay.units.entries')) ]), // Entities Tab activeTab.value === 'entities' && props.result.entities.length > 0 && h('div', { class: 'entities-panel' }, [ h('div', { class: 'panel-header' }, [ - h('span', { class: 'panel-title' }, '核心实体'), - h('span', { class: 'panel-count' }, `共 ${props.result.entities.length} 个`) + h('span', { class: 'panel-title' }, t('step4.toolDisplay.insight.panels.entities')), + h('span', { class: 'panel-count' }, t('step4.toolDisplay.countItems', { count: props.result.entities.length })) ]), h('div', { class: 'entities-grid' }, (expandedEntities.value ? props.result.entities : props.result.entities.slice(0, 12)).map((entity, i) => h('div', { class: 'entity-tag', key: i, title: entity.summary || '' }, [ h('span', { class: 'entity-name' }, entity.name), h('span', { class: 'entity-type' }, entity.type), - entity.relatedFactsCount > 0 && h('span', { class: 'entity-fact-count' }, `${entity.relatedFactsCount}条`) + entity.relatedFactsCount > 0 && h('span', { class: 'entity-fact-count' }, t('step4.toolDisplay.countEntries', { count: entity.relatedFactsCount })) ]) ) ), props.result.entities.length > 12 && h('button', { class: 'expand-btn', onClick: () => { expandedEntities.value = !expandedEntities.value } - }, expandedEntities.value ? `收起 ▲` : `展开全部 ${props.result.entities.length} 个 ▼`) + }, formatExpandLabel(expandedEntities.value, props.result.entities.length, 'step4.toolDisplay.units.items')) ]), // Relations Tab activeTab.value === 'relations' && props.result.relations.length > 0 && h('div', { class: 'relations-panel' }, [ h('div', { class: 'panel-header' }, [ - h('span', { class: 'panel-title' }, '关系链'), - h('span', { class: 'panel-count' }, `共 ${props.result.relations.length} 条`) + h('span', { class: 'panel-title' }, t('step4.toolDisplay.insight.panels.relations')), + h('span', { class: 'panel-count' }, t('step4.toolDisplay.countEntries', { count: props.result.relations.length })) ]), h('div', { class: 'relations-list' }, (expandedRelations.value ? props.result.relations : props.result.relations.slice(0, INITIAL_SHOW_COUNT)).map((rel, i) => @@ -1101,14 +905,14 @@ const InsightDisplay = { props.result.relations.length > INITIAL_SHOW_COUNT && h('button', { class: 'expand-btn', onClick: () => { expandedRelations.value = !expandedRelations.value } - }, expandedRelations.value ? `收起 ▲` : `展开全部 ${props.result.relations.length} 条 ▼`) + }, formatExpandLabel(expandedRelations.value, props.result.relations.length, 'step4.toolDisplay.units.entries')) ]), // Sub-queries Tab activeTab.value === 'subqueries' && props.result.subQueries.length > 0 && h('div', { class: 'subqueries-panel' }, [ h('div', { class: 'panel-header' }, [ - h('span', { class: 'panel-title' }, '漂移查询生成分析子问题'), - h('span', { class: 'panel-count' }, `共 ${props.result.subQueries.length} 个`) + h('span', { class: 'panel-title' }, t('step4.toolDisplay.insight.panels.subqueries')), + h('span', { class: 'panel-count' }, t('step4.toolDisplay.countItems', { count: props.result.subQueries.length })) ]), h('div', { class: 'subqueries-list' }, props.result.subQueries.map((sq, i) => @@ -1121,9 +925,9 @@ const InsightDisplay = { ]), // Empty state - activeTab.value === 'facts' && props.result.facts.length === 0 && h('div', { class: 'empty-state' }, '暂无当前关键记忆'), - activeTab.value === 'entities' && props.result.entities.length === 0 && h('div', { class: 'empty-state' }, '暂无核心实体'), - activeTab.value === 'relations' && props.result.relations.length === 0 && h('div', { class: 'empty-state' }, '暂无关系链') + activeTab.value === 'facts' && props.result.facts.length === 0 && h('div', { class: 'empty-state' }, t('step4.toolDisplay.insight.empty.facts')), + activeTab.value === 'entities' && props.result.entities.length === 0 && h('div', { class: 'empty-state' }, t('step4.toolDisplay.insight.empty.entities')), + activeTab.value === 'relations' && props.result.relations.length === 0 && h('div', { class: 'empty-state' }, t('step4.toolDisplay.insight.empty.relations')) ]) ]) } @@ -1143,25 +947,25 @@ const PanoramaDisplay = { const formatSize = (length) => { if (!length) return '' if (length >= 1000) { - return `${(length / 1000).toFixed(1)}k chars` + return t('step4.toolDisplay.charCountCompact', { count: (length / 1000).toFixed(1) }) } - return `${length} chars` + return t('step4.toolDisplay.charCount', { count: length }) } return () => h('div', { class: 'panorama-display' }, [ // Header Section h('div', { class: 'panorama-header' }, [ h('div', { class: 'header-main' }, [ - h('div', { class: 'header-title' }, 'Panorama Search'), + h('div', { class: 'header-title' }, t('step4.toolNames.panoramaSearch')), h('div', { class: 'header-stats' }, [ h('span', { class: 'stat-item' }, [ h('span', { class: 'stat-value' }, props.result.stats.nodes), - h('span', { class: 'stat-label' }, 'Nodes') + h('span', { class: 'stat-label' }, t('step4.toolDisplay.labels.nodes')) ]), h('span', { class: 'stat-divider' }, '/'), h('span', { class: 'stat-item' }, [ h('span', { class: 'stat-value' }, props.result.stats.edges), - h('span', { class: 'stat-label' }, 'Edges') + h('span', { class: 'stat-label' }, t('step4.toolDisplay.labels.edges')) ]), props.resultLength && h('span', { class: 'stat-divider' }, '·'), props.resultLength && h('span', { class: 'stat-size' }, formatSize(props.resultLength)) @@ -1176,19 +980,19 @@ const PanoramaDisplay = { class: ['panorama-tab', { active: activeTab.value === 'active' }], onClick: () => { activeTab.value = 'active' } }, [ - h('span', { class: 'tab-label' }, `当前有效记忆 (${props.result.activeFacts.length})`) + h('span', { class: 'tab-label' }, t('step4.toolDisplay.panorama.tabs.active', { count: props.result.activeFacts.length })) ]), h('button', { class: ['panorama-tab', { active: activeTab.value === 'historical' }], onClick: () => { activeTab.value = 'historical' } }, [ - h('span', { class: 'tab-label' }, `历史记忆 (${props.result.historicalFacts.length})`) + h('span', { class: 'tab-label' }, t('step4.toolDisplay.panorama.tabs.historical', { count: props.result.historicalFacts.length })) ]), h('button', { class: ['panorama-tab', { active: activeTab.value === 'entities' }], onClick: () => { activeTab.value = 'entities' } }, [ - h('span', { class: 'tab-label' }, `涉及实体 (${props.result.entities.length})`) + h('span', { class: 'tab-label' }, t('step4.toolDisplay.panorama.tabs.entities', { count: props.result.entities.length })) ]) ]), @@ -1197,8 +1001,8 @@ const PanoramaDisplay = { // Active Facts Tab activeTab.value === 'active' && h('div', { class: 'facts-panel active-facts' }, [ h('div', { class: 'panel-header' }, [ - h('span', { class: 'panel-title' }, '当前有效记忆'), - h('span', { class: 'panel-count' }, `共 ${props.result.activeFacts.length} 条`) + h('span', { class: 'panel-title' }, t('step4.toolDisplay.panorama.panels.active')), + h('span', { class: 'panel-count' }, t('step4.toolDisplay.countEntries', { count: props.result.activeFacts.length })) ]), props.result.activeFacts.length > 0 ? h('div', { class: 'facts-list' }, (expandedActive.value ? props.result.activeFacts : props.result.activeFacts.slice(0, INITIAL_SHOW_COUNT)).map((fact, i) => @@ -1207,18 +1011,18 @@ const PanoramaDisplay = { h('div', { class: 'fact-content' }, fact) ]) ) - ) : h('div', { class: 'empty-state' }, '暂无当前有效记忆'), + ) : h('div', { class: 'empty-state' }, t('step4.toolDisplay.panorama.empty.active')), props.result.activeFacts.length > INITIAL_SHOW_COUNT && h('button', { class: 'expand-btn', onClick: () => { expandedActive.value = !expandedActive.value } - }, expandedActive.value ? `收起 ▲` : `展开全部 ${props.result.activeFacts.length} 条 ▼`) + }, formatExpandLabel(expandedActive.value, props.result.activeFacts.length, 'step4.toolDisplay.units.entries')) ]), // Historical Facts Tab activeTab.value === 'historical' && h('div', { class: 'facts-panel historical-facts' }, [ h('div', { class: 'panel-header' }, [ - h('span', { class: 'panel-title' }, '历史记忆'), - h('span', { class: 'panel-count' }, `共 ${props.result.historicalFacts.length} 条`) + h('span', { class: 'panel-title' }, t('step4.toolDisplay.panorama.panels.historical')), + h('span', { class: 'panel-count' }, t('step4.toolDisplay.countEntries', { count: props.result.historicalFacts.length })) ]), props.result.historicalFacts.length > 0 ? h('div', { class: 'facts-list' }, (expandedHistorical.value ? props.result.historicalFacts : props.result.historicalFacts.slice(0, INITIAL_SHOW_COUNT)).map((fact, i) => @@ -1239,18 +1043,18 @@ const PanoramaDisplay = { ]) ]) ) - ) : h('div', { class: 'empty-state' }, '暂无历史记忆'), + ) : h('div', { class: 'empty-state' }, t('step4.toolDisplay.panorama.empty.historical')), props.result.historicalFacts.length > INITIAL_SHOW_COUNT && h('button', { class: 'expand-btn', onClick: () => { expandedHistorical.value = !expandedHistorical.value } - }, expandedHistorical.value ? `收起 ▲` : `展开全部 ${props.result.historicalFacts.length} 条 ▼`) + }, formatExpandLabel(expandedHistorical.value, props.result.historicalFacts.length, 'step4.toolDisplay.units.entries')) ]), // Entities Tab activeTab.value === 'entities' && h('div', { class: 'entities-panel' }, [ h('div', { class: 'panel-header' }, [ - h('span', { class: 'panel-title' }, '涉及实体'), - h('span', { class: 'panel-count' }, `共 ${props.result.entities.length} 个`) + h('span', { class: 'panel-title' }, t('step4.toolDisplay.panorama.panels.entities')), + h('span', { class: 'panel-count' }, t('step4.toolDisplay.countItems', { count: props.result.entities.length })) ]), props.result.entities.length > 0 ? h('div', { class: 'entities-grid' }, (expandedEntities.value ? props.result.entities : props.result.entities.slice(0, 8)).map((entity, i) => @@ -1259,11 +1063,11 @@ const PanoramaDisplay = { entity.type && h('span', { class: 'entity-type' }, entity.type) ]) ) - ) : h('div', { class: 'empty-state' }, '暂无涉及实体'), + ) : h('div', { class: 'empty-state' }, t('step4.toolDisplay.panorama.empty.entities')), props.result.entities.length > 8 && h('button', { class: 'expand-btn', onClick: () => { expandedEntities.value = !expandedEntities.value } - }, expandedEntities.value ? `收起 ▲` : `展开全部 ${props.result.entities.length} 个 ▼`) + }, formatExpandLabel(expandedEntities.value, props.result.entities.length, 'step4.toolDisplay.units.items')) ]) ]) ]) @@ -1278,9 +1082,9 @@ const InterviewDisplay = { const formatSize = (length) => { if (!length) return '' if (length >= 1000) { - return `${(length / 1000).toFixed(1)}k chars` + return t('step4.toolDisplay.charCountCompact', { count: (length / 1000).toFixed(1) }) } - return `${length} chars` + return t('step4.toolDisplay.charCount', { count: length }) } // Clean quote text - remove leading list numbers to avoid double numbering @@ -1323,116 +1127,29 @@ const InterviewDisplay = { return text.substring(0, 400) + '...' } - // 检查是否为平台占位文本 - const isPlaceholderText = (text) => { - if (!text) return true - const t = text.trim() - return t === '(该平台未获得回复)' || t === '(该平台未获得回复)' || t === '[无回复]' - } - - // 尝试按问题编号分割回答 - const splitAnswerByQuestions = (answerText, questionCount) => { - if (!answerText || questionCount <= 0) return [answerText] - if (isPlaceholderText(answerText)) return [''] - - // 支持两种编号格式: - // 1. "问题X:" 或 "问题X:" (中文格式,后端新格式) - // 2. "1. " 或 "\n1. " (数字+点,旧格式兼容) - let matches = [] - let match - - // 优先尝试 "问题X:" 格式 - const cnPattern = /(?:^|[\r\n]+)问题(\d+)[::]\s*/g - while ((match = cnPattern.exec(answerText)) !== null) { - matches.push({ - num: parseInt(match[1]), - index: match.index, - fullMatch: match[0] - }) - } - - // 如果没匹配到,回退到 "数字." 格式 - if (matches.length === 0) { - const numPattern = /(?:^|[\r\n]+)(\d+)\.\s+/g - while ((match = numPattern.exec(answerText)) !== null) { - matches.push({ - num: parseInt(match[1]), - index: match.index, - fullMatch: match[0] - }) - } - } - - // 如果没有找到编号或只找到一个,返回整体 - if (matches.length <= 1) { - const cleaned = answerText - .replace(/^问题\d+[::]\s*/, '') - .replace(/^\d+\.\s+/, '') - .trim() - return [cleaned || answerText] - } - - // 按编号提取各部分 - const parts = [] - for (let i = 0; i < matches.length; i++) { - const current = matches[i] - const next = matches[i + 1] - - const startIdx = current.index + current.fullMatch.length - const endIdx = next ? next.index : answerText.length - - let part = answerText.substring(startIdx, endIdx).trim() - part = part.replace(/[\r\n]+$/, '').trim() - parts.push(part) - } - - if (parts.length > 0 && parts.some(p => p)) { - return parts - } - - return [answerText] - } - - // 获取某个问题对应的回答 - const getAnswerForQuestion = (interview, qIdx, platform) => { - const answer = platform === 'twitter' ? interview.twitterAnswer : (interview.redditAnswer || interview.twitterAnswer) - if (!answer || isPlaceholderText(answer)) return answer || '' - - const questionCount = interview.questions?.length || 1 - const answers = splitAnswerByQuestions(answer, questionCount) - - // 分割成功且索引有效 - if (answers.length > 1 && qIdx < answers.length) { - return answers[qIdx] || '' - } - - // 分割失败:第一个问题返回完整回答,其余返回空 - return qIdx === 0 ? answer : '' - } - // 检查某个问题是否有双平台回答(过滤占位文本) const hasMultiplePlatforms = (interview, qIdx) => { if (!interview.twitterAnswer || !interview.redditAnswer) return false - const twitterAnswer = getAnswerForQuestion(interview, qIdx, 'twitter') - const redditAnswer = getAnswerForQuestion(interview, qIdx, 'reddit') + const twitterAnswer = getInterviewAnswerForQuestion(interview, qIdx, 'twitter') + const redditAnswer = getInterviewAnswerForQuestion(interview, qIdx, 'reddit') // 两个平台都有真实回答(非占位文本)且内容不同 - return !isPlaceholderText(twitterAnswer) && !isPlaceholderText(redditAnswer) && twitterAnswer !== redditAnswer + return !isMissingPlatformReply(twitterAnswer) && !isMissingPlatformReply(redditAnswer) && twitterAnswer !== redditAnswer } return () => h('div', { class: 'interview-display' }, [ // Header Section h('div', { class: 'interview-header' }, [ h('div', { class: 'header-main' }, [ - h('div', { class: 'header-title' }, 'Agent Interview'), + h('div', { class: 'header-title' }, t('step4.toolNames.interviewAgents')), h('div', { class: 'header-stats' }, [ h('span', { class: 'stat-item' }, [ h('span', { class: 'stat-value' }, props.result.successCount || props.result.interviews.length), - h('span', { class: 'stat-label' }, 'Interviewed') + h('span', { class: 'stat-label' }, t('step4.toolDisplay.labels.interviewed')) ]), props.result.totalCount > 0 && h('span', { class: 'stat-divider' }, '/'), props.result.totalCount > 0 && h('span', { class: 'stat-item' }, [ h('span', { class: 'stat-value' }, props.result.totalCount), - h('span', { class: 'stat-label' }, 'Total') + h('span', { class: 'stat-label' }, t('step4.toolDisplay.labels.total')) ]), props.resultLength && h('span', { class: 'stat-divider' }, '·'), props.resultLength && h('span', { class: 'stat-size' }, formatSize(props.resultLength)) @@ -1449,7 +1166,7 @@ const InterviewDisplay = { onClick: () => { activeIndex.value = i } }, [ h('span', { class: 'tab-avatar' }, interview.name ? interview.name.charAt(0) : (i + 1)), - h('span', { class: 'tab-name' }, interview.title || interview.name || `Agent ${i + 1}`) + h('span', { class: 'tab-name' }, interview.title || interview.name || t('step4.toolDisplay.interview.agentIndex', { index: i + 1 })) ])) ), @@ -1459,7 +1176,7 @@ const InterviewDisplay = { h('div', { class: 'agent-profile' }, [ h('div', { class: 'profile-avatar' }, props.result.interviews[activeIndex.value]?.name?.charAt(0) || 'A'), h('div', { class: 'profile-info' }, [ - h('div', { class: 'profile-name' }, props.result.interviews[activeIndex.value]?.name || 'Agent'), + h('div', { class: 'profile-name' }, props.result.interviews[activeIndex.value]?.name || t('step4.toolDisplay.interview.agentFallback')), h('div', { class: 'profile-role' }, props.result.interviews[activeIndex.value]?.role || ''), props.result.interviews[activeIndex.value]?.bio && h('div', { class: 'profile-bio' }, props.result.interviews[activeIndex.value].bio) ]) @@ -1467,7 +1184,7 @@ const InterviewDisplay = { // Selection Reason - 选择理由 props.result.interviews[activeIndex.value]?.selectionReason && h('div', { class: 'selection-reason' }, [ - h('div', { class: 'reason-label' }, '选择理由'), + h('div', { class: 'reason-label' }, t('step4.toolDisplay.interview.selectionReason')), h('div', { class: 'reason-content' }, props.result.interviews[activeIndex.value].selectionReason) ]), @@ -1475,22 +1192,22 @@ const InterviewDisplay = { h('div', { class: 'qa-thread' }, (props.result.interviews[activeIndex.value]?.questions?.length > 0 ? props.result.interviews[activeIndex.value].questions - : [props.result.interviews[activeIndex.value]?.question || 'No question available'] + : [props.result.interviews[activeIndex.value]?.question || t('step4.toolDisplay.interview.noQuestion')] ).map((question, qIdx) => { const interview = props.result.interviews[activeIndex.value] const currentPlatform = getPlatformTab(activeIndex.value, qIdx) - const answerText = getAnswerForQuestion(interview, qIdx, currentPlatform) + const answerText = getInterviewAnswerForQuestion(interview, qIdx, currentPlatform) const hasDualPlatform = hasMultiplePlatforms(interview, qIdx) const expandKey = `${activeIndex.value}-${qIdx}` const isExpanded = expandedAnswers.value.has(expandKey) - const isPlaceholder = isPlaceholderText(answerText) + const isPlaceholder = isMissingPlatformReply(answerText) return h('div', { class: 'qa-pair', key: qIdx }, [ // Question Block h('div', { class: 'qa-question' }, [ h('div', { class: 'qa-badge q-badge' }, `Q${qIdx + 1}`), h('div', { class: 'qa-content' }, [ - h('div', { class: 'qa-sender' }, 'Interviewer'), + h('div', { class: 'qa-sender' }, t('step4.toolDisplay.interview.interviewer')), h('div', { class: 'qa-text' }, question) ]) ]), @@ -1500,7 +1217,7 @@ const InterviewDisplay = { h('div', { class: 'qa-badge a-badge' }, `A${qIdx + 1}`), h('div', { class: 'qa-content' }, [ h('div', { class: 'qa-answer-header' }, [ - h('div', { class: 'qa-sender' }, interview?.name || 'Agent'), + h('div', { class: 'qa-sender' }, interview?.name || t('step4.toolDisplay.interview.agentFallback')), // 双平台切换按钮(仅在有真实双平台回答时显示) hasDualPlatform && h('div', { class: 'platform-switch' }, [ h('button', { @@ -1512,7 +1229,7 @@ const InterviewDisplay = { h('line', { x1: '2', y1: '12', x2: '22', y2: '12' }), h('path', { d: 'M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z' }) ]), - h('span', {}, '世界1') + h('span', {}, t('step4.toolDisplay.interview.worldOne')) ]), h('button', { class: ['platform-btn', { active: currentPlatform === 'reddit' }], @@ -1521,7 +1238,7 @@ const InterviewDisplay = { h('svg', { class: 'platform-icon', viewBox: '0 0 24 24', width: 12, height: 12, fill: 'none', stroke: 'currentColor', 'stroke-width': 2 }, [ h('path', { d: 'M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z' }) ]), - h('span', {}, '世界2') + h('span', {}, t('step4.toolDisplay.interview.worldTwo')) ]) ]) ]), @@ -1537,7 +1254,7 @@ const InterviewDisplay = { !isPlaceholder && answerText.length > 400 && h('button', { class: 'expand-answer-btn', onClick: () => toggleAnswer(expandKey) - }, isExpanded ? 'Show Less' : 'Show More') + }, isExpanded ? t('step4.toolDisplay.showLess') : t('step4.toolDisplay.interview.showMore')) ]) ]) ]) @@ -1546,7 +1263,7 @@ const InterviewDisplay = { // Key Quotes Section props.result.interviews[activeIndex.value]?.quotes?.length > 0 && h('div', { class: 'quotes-section' }, [ - h('div', { class: 'quotes-header' }, 'Key Quotes'), + h('div', { class: 'quotes-header' }, t('step4.toolDisplay.interview.keyQuotes')), h('div', { class: 'quotes-list' }, props.result.interviews[activeIndex.value].quotes.slice(0, 3).map((quote, qi) => { const cleanedQuote = cleanQuoteText(quote) @@ -1563,7 +1280,7 @@ const InterviewDisplay = { // Summary Section (Collapsible) props.result.summary && h('div', { class: 'summary-section' }, [ - h('div', { class: 'summary-header' }, 'Interview Summary'), + h('div', { class: 'summary-header' }, t('step4.toolDisplay.interview.summary')), h('div', { class: 'summary-content', innerHTML: renderMarkdown(props.result.summary.length > 500 ? props.result.summary.substring(0, 500) + '...' : props.result.summary) @@ -1590,27 +1307,27 @@ const QuickSearchDisplay = { const formatSize = (length) => { if (!length) return '' if (length >= 1000) { - return `${(length / 1000).toFixed(1)}k chars` + return t('step4.toolDisplay.charCountCompact', { count: (length / 1000).toFixed(1) }) } - return `${length} chars` + return t('step4.toolDisplay.charCount', { count: length }) } return () => h('div', { class: 'quick-search-display' }, [ // Header Section h('div', { class: 'quicksearch-header' }, [ h('div', { class: 'header-main' }, [ - h('div', { class: 'header-title' }, 'Quick Search'), + h('div', { class: 'header-title' }, t('step4.toolNames.quickSearch')), h('div', { class: 'header-stats' }, [ h('span', { class: 'stat-item' }, [ h('span', { class: 'stat-value' }, props.result.count || props.result.facts.length), - h('span', { class: 'stat-label' }, 'Results') + h('span', { class: 'stat-label' }, t('step4.toolDisplay.labels.results')) ]), props.resultLength && h('span', { class: 'stat-divider' }, '·'), props.resultLength && h('span', { class: 'stat-size' }, formatSize(props.resultLength)) ]) ]), props.result.query && h('div', { class: 'header-query' }, [ - h('span', { class: 'query-label' }, '搜索: '), + h('span', { class: 'query-label' }, t('step4.toolDisplay.quickSearch.searchLabel')), h('span', { class: 'query-text' }, props.result.query) ]) ]), @@ -1621,19 +1338,19 @@ const QuickSearchDisplay = { class: ['quicksearch-tab', { active: activeTab.value === 'facts' }], onClick: () => { activeTab.value = 'facts' } }, [ - h('span', { class: 'tab-label' }, `事实 (${props.result.facts.length})`) + h('span', { class: 'tab-label' }, t('step4.toolDisplay.quickSearch.tabs.facts', { count: props.result.facts.length })) ]), hasEdges.value && h('button', { class: ['quicksearch-tab', { active: activeTab.value === 'edges' }], onClick: () => { activeTab.value = 'edges' } }, [ - h('span', { class: 'tab-label' }, `关系 (${props.result.edges.length})`) + h('span', { class: 'tab-label' }, t('step4.toolDisplay.quickSearch.tabs.edges', { count: props.result.edges.length })) ]), hasNodes.value && h('button', { class: ['quicksearch-tab', { active: activeTab.value === 'nodes' }], onClick: () => { activeTab.value = 'nodes' } }, [ - h('span', { class: 'tab-label' }, `节点 (${props.result.nodes.length})`) + h('span', { class: 'tab-label' }, t('step4.toolDisplay.quickSearch.tabs.nodes', { count: props.result.nodes.length })) ]) ]), @@ -1642,8 +1359,8 @@ const QuickSearchDisplay = { // Facts (always show if no tabs, or when facts tab is active) ((!showTabs.value) || activeTab.value === 'facts') && h('div', { class: 'facts-panel' }, [ !showTabs.value && h('div', { class: 'panel-header' }, [ - h('span', { class: 'panel-title' }, '搜索结果'), - h('span', { class: 'panel-count' }, `共 ${props.result.facts.length} 条`) + h('span', { class: 'panel-title' }, t('step4.toolDisplay.quickSearch.panels.results')), + h('span', { class: 'panel-count' }, t('step4.toolDisplay.countEntries', { count: props.result.facts.length })) ]), props.result.facts.length > 0 ? h('div', { class: 'facts-list' }, (expandedFacts.value ? props.result.facts : props.result.facts.slice(0, INITIAL_SHOW_COUNT)).map((fact, i) => @@ -1652,18 +1369,18 @@ const QuickSearchDisplay = { h('div', { class: 'fact-content' }, fact) ]) ) - ) : h('div', { class: 'empty-state' }, '未找到相关结果'), + ) : h('div', { class: 'empty-state' }, t('step4.toolDisplay.quickSearch.empty.results')), props.result.facts.length > INITIAL_SHOW_COUNT && h('button', { class: 'expand-btn', onClick: () => { expandedFacts.value = !expandedFacts.value } - }, expandedFacts.value ? `收起 ▲` : `展开全部 ${props.result.facts.length} 条 ▼`) + }, formatExpandLabel(expandedFacts.value, props.result.facts.length, 'step4.toolDisplay.units.entries')) ]), // Edges Tab activeTab.value === 'edges' && hasEdges.value && h('div', { class: 'edges-panel' }, [ h('div', { class: 'panel-header' }, [ - h('span', { class: 'panel-title' }, '相关关系'), - h('span', { class: 'panel-count' }, `共 ${props.result.edges.length} 条`) + h('span', { class: 'panel-title' }, t('step4.toolDisplay.quickSearch.panels.edges')), + h('span', { class: 'panel-count' }, t('step4.toolDisplay.countEntries', { count: props.result.edges.length })) ]), h('div', { class: 'edges-list' }, props.result.edges.map((edge, i) => @@ -1683,8 +1400,8 @@ const QuickSearchDisplay = { // Nodes Tab activeTab.value === 'nodes' && hasNodes.value && h('div', { class: 'nodes-panel' }, [ h('div', { class: 'panel-header' }, [ - h('span', { class: 'panel-title' }, '相关节点'), - h('span', { class: 'panel-count' }, `共 ${props.result.nodes.length} 个`) + h('span', { class: 'panel-title' }, t('step4.toolDisplay.quickSearch.panels.nodes')), + h('span', { class: 'panel-count' }, t('step4.toolDisplay.countItems', { count: props.result.nodes.length })) ]), h('div', { class: 'nodes-grid' }, props.result.nodes.map((node, i) => @@ -1702,15 +1419,23 @@ const QuickSearchDisplay = { // Computed const statusClass = computed(() => { + if (isFailed.value) return 'failed' if (isComplete.value) return 'completed' if (agentLogs.value.length > 0) return 'processing' return 'pending' }) const statusText = computed(() => { - if (isComplete.value) return 'Completed' - if (agentLogs.value.length > 0) return 'Generating...' - return 'Waiting' + if (isFailed.value) return t('step4.status.failed') + if (isComplete.value) return t('step4.status.completed') + if (agentLogs.value.length > 0) return t('step4.status.generating') + return t('step4.status.waiting') +}) + +const isFailed = computed(() => reportStatus.value === 'failed') + +const failureMessage = computed(() => { + return reportError.value || t('step4.failureFallback') }) const totalSections = computed(() => { @@ -1776,7 +1501,7 @@ const activeStep = computed(() => { if (doneSteps.length > 0) return doneSteps[doneSteps.length - 1] // 否则返回第一个步骤 - return steps[0] || { noLabel: '--', title: '等待开始', status: 'todo', meta: '' } + return steps[0] || { noLabel: '--', title: t('step4.waitingToStart'), status: 'todo', meta: '' } }) const workflowSteps = computed(() => { @@ -1787,9 +1512,9 @@ const workflowSteps = computed(() => { steps.push({ key: 'planning', noLabel: 'PL', - title: 'Planning / Outline', + title: t('step4.planningOutline'), status: planningStatus, - meta: planningStatus === 'active' ? 'IN PROGRESS' : '' + meta: planningStatus === 'active' ? t('step4.inProgress') : '' }) // Sections (if outline exists) @@ -1805,7 +1530,7 @@ const workflowSteps = computed(() => { noLabel: String(idx).padStart(2, '0'), title: section.title, status, - meta: status === 'active' ? 'IN PROGRESS' : '' + meta: status === 'active' ? t('step4.inProgress') : '' }) }) @@ -1814,9 +1539,9 @@ const workflowSteps = computed(() => { steps.push({ key: 'complete', noLabel: 'OK', - title: 'Complete', + title: t('step4.complete'), status: completeStatus, - meta: completeStatus === 'active' ? 'FINALIZING' : '' + meta: completeStatus === 'active' ? t('step4.finalizing') : '' }) return steps @@ -1834,11 +1559,11 @@ const isSectionCompleted = (sectionIndex) => { const formatTime = (timestamp) => { if (!timestamp) return '' try { - return new Date(timestamp).toLocaleTimeString('en-US', { - hour12: false, - hour: '2-digit', - minute: '2-digit', - second: '2-digit' + return new Date(timestamp).toLocaleTimeString(locale.value === 'zh' ? 'zh-CN' : 'en-US', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit' }) } catch { return '' @@ -1856,8 +1581,8 @@ const formatParams = (params) => { const formatResultSize = (length) => { if (!length) return '' - if (length < 1000) return `${length} chars` - return `${(length / 1000).toFixed(1)}k chars` + if (length < 1000) return t('step4.toolDisplay.charCount', { count: length }) + return t('step4.toolDisplay.charCountCompact', { count: (length / 1000).toFixed(1) }) } const truncateText = (text, maxLen) => { @@ -1991,16 +1716,16 @@ const getConnectorClass = (log, idx, total) => { const getActionLabel = (action) => { const labels = { - 'report_start': 'Report Started', - 'planning_start': 'Planning', - 'planning_complete': 'Plan Complete', - 'section_start': 'Section Start', - 'section_content': 'Content Ready', - 'section_complete': 'Section Done', - 'tool_call': 'Tool Call', - 'tool_result': 'Tool Result', - 'llm_response': 'LLM Response', - 'report_complete': 'Complete' + 'report_start': t('step4.actions.reportStart'), + 'planning_start': t('step4.actions.planning'), + 'planning_complete': t('step4.actions.planComplete'), + 'section_start': t('step4.actions.sectionStart'), + 'section_content': t('step4.actions.contentReady'), + 'section_complete': t('step4.actions.sectionDone'), + 'tool_call': t('step4.actions.toolCall'), + 'tool_result': t('step4.actions.toolResult'), + 'llm_response': t('step4.actions.llmResponse'), + 'report_complete': t('step4.actions.complete') } return labels[action] || action } @@ -2015,6 +1740,7 @@ const getLogLevelClass = (log) => { // Polling let agentLogTimer = null let consoleLogTimer = null +let reportStateTimer = null const fetchAgentLog = async () => { if (!props.reportId) return @@ -2079,51 +1805,6 @@ const fetchAgentLog = async () => { } } -// 提取最终答案内容 - 从 LLM response 中提取章节内容 -const extractFinalContent = (response) => { - if (!response) return null - - // 尝试提取 <final_answer> 标签内的内容 - const finalAnswerTagMatch = response.match(/<final_answer>([\s\S]*?)<\/final_answer>/) - if (finalAnswerTagMatch) { - return finalAnswerTagMatch[1].trim() - } - - // 尝试找 Final Answer: 后面的内容(支持多种格式) - // 格式1: Final Answer:\n\n内容 - // 格式2: Final Answer: 内容 - const finalAnswerMatch = response.match(/Final\s*Answer:\s*\n*([\s\S]*)$/i) - if (finalAnswerMatch) { - return finalAnswerMatch[1].trim() - } - - // 尝试找 最终答案: 后面的内容 - const chineseFinalMatch = response.match(/最终答案[::]\s*\n*([\s\S]*)$/i) - if (chineseFinalMatch) { - return chineseFinalMatch[1].trim() - } - - // 如果以 ## 或 # 或 > 开头,可能是直接的 markdown 内容 - const trimmedResponse = response.trim() - if (trimmedResponse.match(/^[#>]/)) { - return trimmedResponse - } - - // 如果内容较长且包含markdown格式,尝试移除思考过程后返回 - if (response.length > 300 && (response.includes('**') || response.includes('>'))) { - // 移除 Thought: 开头的思考过程 - const thoughtMatch = response.match(/^Thought:[\s\S]*?(?=\n\n[^T]|\n\n$)/i) - if (thoughtMatch) { - const afterThought = response.substring(thoughtMatch[0].length).trim() - if (afterThought.length > 100) { - return afterThought - } - } - } - - return null -} - const fetchConsoleLog = async () => { if (!props.reportId) return @@ -2150,16 +1831,22 @@ const fetchConsoleLog = async () => { } const startPolling = () => { - if (agentLogTimer || consoleLogTimer) return + if (agentLogTimer || consoleLogTimer || reportStateTimer) return + syncReportState() fetchAgentLog() fetchConsoleLog() + reportStateTimer = setInterval(syncReportState, 3000) agentLogTimer = setInterval(fetchAgentLog, 2000) consoleLogTimer = setInterval(fetchConsoleLog, 1500) } const stopPolling = () => { + if (reportStateTimer) { + clearInterval(reportStateTimer) + reportStateTimer = null + } if (agentLogTimer) { clearInterval(agentLogTimer) agentLogTimer = null @@ -2173,12 +1860,13 @@ const stopPolling = () => { // Lifecycle onMounted(() => { if (props.reportId) { - addLog(`Report Agent initialized: ${props.reportId}`) + addLog(t('step4.reportAgentInitialized', { id: props.reportId })) startPolling() } }) onUnmounted(() => { + clearCopiedReferenceTimer() stopPolling() }) @@ -2195,7 +1883,11 @@ watch(() => props.reportId, (newId) => { expandedLogs.value = new Set() collapsedSections.value = new Set() isComplete.value = false + reportStatus.value = null + reportError.value = '' + isRetrying.value = false startTime.value = null + reportTimestamp.value = '' startPolling() } @@ -2364,7 +2056,7 @@ watch(() => props.reportId, (newId) => { display: flex; align-items: center; gap: 12px; - margin-bottom: 24px; + margin-bottom: 16px; } .report-tag { @@ -2377,11 +2069,87 @@ watch(() => props.reportId, (newId) => { text-transform: uppercase; } -.report-id { +.report-reference-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; + margin-bottom: 16px; +} + +.report-reference-card { + display: flex; + flex-direction: column; + gap: 6px; + padding: 12px 14px; + border: 1px solid #E5E7EB; + background: #F9FAFB; + border-radius: 10px; +} + +.report-reference-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.report-reference-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.report-reference-label { font-size: 11px; - color: #9CA3AF; - font-weight: 500; - letter-spacing: 0.02em; + font-weight: 700; + color: #6B7280; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.report-reference-copy { + border: 1px solid #D1D5DB; + background: #FFFFFF; + color: #374151; + border-radius: 999px; + padding: 4px 10px; + font-size: 11px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.report-reference-copy:hover:not(:disabled) { + border-color: #111827; + color: #111827; +} + +.report-reference-copy:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.report-reference-bundle-row { + display: flex; + justify-content: flex-end; + margin: -4px 0 12px; +} + +.report-reference-value { + font-size: 12px; + color: #111827; + font-weight: 600; + letter-spacing: 0.01em; + word-break: break-word; +} + +.report-reference-hint { + margin: 0 0 20px 0; + font-size: 13px; + line-height: 1.5; + color: #4B5563; } .main-title { @@ -2660,6 +2428,91 @@ watch(() => props.reportId, (newId) => { font-size: 14px; } +.failed-placeholder { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + padding: 40px; + text-align: center; + color: #7F1D1D; + background: linear-gradient(180deg, #FFFFFF 0%, #FEF2F2 100%); +} + +.failed-icon { + width: 56px; + height: 56px; + display: grid; + place-items: center; + border-radius: 999px; + background: #FEE2E2; + color: #B91C1C; +} + +.failed-icon svg { + width: 28px; + height: 28px; +} + +.failed-title { + margin: 0; + font-size: 22px; + font-weight: 700; + color: #991B1B; +} + +.failed-text { + max-width: 520px; + margin: 0; + font-size: 14px; + line-height: 1.6; + color: #7F1D1D; +} + +.failed-actions, +.failure-banner-actions { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.retry-report-btn, +.failure-banner-btn { + border: 0; + border-radius: 999px; + background: #111827; + color: #FFFFFF; + cursor: pointer; + font-weight: 700; + transition: transform 0.2s ease, opacity 0.2s ease; +} + +.retry-report-btn { + padding: 12px 18px; + font-size: 14px; +} + +.retry-report-btn:hover, +.failure-banner-btn:hover { + transform: translateY(-1px); +} + +.retry-report-btn:disabled, +.failure-banner-btn:disabled { + opacity: 0.55; + cursor: not-allowed; + transform: none; +} + +.retry-report-btn.secondary, +.failure-banner-btn.secondary { + border: 1px solid #D1D5DB; + background: #FFFFFF; + color: #111827; +} + /* Right Panel */ .right-panel { flex: 1; @@ -2777,6 +2630,49 @@ watch(() => props.reportId, (newId) => { color: #6B7280; } +.metric-pill.pill--failed { + background: #FEF2F2; + border-color: #FECACA; + color: #991B1B; +} + +.failure-banner { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 12px; + padding: 12px 14px; + border: 1px solid #FECACA; + border-radius: 12px; + background: #FEF2F2; +} + +.failure-banner-copy { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.failure-banner-title { + font-size: 13px; + font-weight: 700; + color: #991B1B; +} + +.failure-banner-text { + font-size: 12px; + line-height: 1.5; + color: #7F1D1D; + word-break: break-word; +} + +.failure-banner-btn { + padding: 8px 14px; + font-size: 12px; + flex-shrink: 0; +} + .workflow-steps { display: flex; flex-direction: column; diff --git a/frontend/src/components/Step5Interaction.vue b/frontend/src/components/Step5Interaction.vue index 3d84c7ce..cf22f4cd 100644 --- a/frontend/src/components/Step5Interaction.vue +++ b/frontend/src/components/Step5Interaction.vue @@ -8,8 +8,8 @@ <!-- Report Header --> <div class="report-header-block"> <div class="report-meta"> - <span class="report-tag">Prediction Report</span> - <span class="report-id">ID: {{ reportId || 'REF-2024-X92' }}</span> + <span class="report-tag">{{ t('step5.predictionReport') }}</span> + <span class="report-id">{{ t('step5.reportId', { id: resolvedReportReference }) }}</span> </div> <h1 class="main-title">{{ reportOutline.title }}</h1> <p class="sub-title">{{ reportOutline.summary }}</p> @@ -58,7 +58,7 @@ <path d="M12 2a10 10 0 0 1 10 10" stroke-width="4" stroke="#4B5563" stroke-linecap="round"></path> </svg> </div> - <span class="loading-text">正在生成{{ section.title }}...</span> + <span class="loading-text">{{ t('step5.sectionGenerating', { title: section.title }) }}</span> </div> </div> </div> @@ -72,7 +72,9 @@ <div class="waiting-ring"></div> <div class="waiting-ring"></div> </div> - <span class="waiting-text">Waiting for Report Agent...</span> + <span class="waiting-text"> + {{ props.reportId ? t('step5.waitingForAgent') : t('step5.interactionOnlyReady') }} + </span> </div> </div> @@ -85,20 +87,40 @@ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path> </svg> <div class="action-bar-text"> - <span class="action-bar-title">Interactive Tools</span> - <span class="action-bar-subtitle mono">{{ profiles.length }} agents available</span> + <span class="action-bar-title">{{ t('step5.interactiveTools') }}</span> + <span class="action-bar-subtitle mono">{{ t('step5.agentsAvailable', { count: profiles.length }) }}</span> </div> + </div> + <div + v-if="getInterviewStatusMessage()" + class="interview-status-banner" + :class="{ warning: !interviewEnvStatus?.env_alive }" + > + {{ getInterviewStatusMessage() }} + </div> + <div v-if="step3RecoveryState" class="step3-recovery-card"> + <div class="step3-recovery-copy"> + <span class="step3-recovery-label">{{ t('step2.savedRunLabel') }}</span> + <p class="step3-recovery-text">{{ t(step3RecoveryState.noticeKey) }}</p> + </div> + <button class="step3-recovery-btn" type="button" @click="openSavedStep3Run"> + {{ t(step3RecoveryState.actionKey) }} + </button> + </div> + <div class="interview-timeout-hint"> + {{ getInterviewTimeoutHint() }} </div> <div class="action-bar-tabs"> <button class="tab-pill" :class="{ active: activeTab === 'chat' && chatTarget === 'report_agent' }" + :disabled="!props.reportId" @click="selectReportAgentChat" > <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"> <path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path> </svg> - <span>与Report Agent对话</span> + <span>{{ t('step5.chatWithReportAgent') }}</span> </button> <div class="agent-dropdown" v-if="profiles.length > 0"> <button @@ -110,13 +132,13 @@ <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path> <circle cx="12" cy="7" r="4"></circle> </svg> - <span>{{ selectedAgent ? selectedAgent.username : '与世界中任意个体对话' }}</span> + <span>{{ selectedAgent ? selectedAgent.username : t('step5.chatWithAnyAgent') }}</span> <svg class="dropdown-arrow" :class="{ open: showAgentDropdown }" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"> <polyline points="6 9 12 15 18 9"></polyline> </svg> </button> <div v-if="showAgentDropdown" class="dropdown-menu"> - <div class="dropdown-header">选择对话对象</div> + <div class="dropdown-header">{{ t('step5.selectChatTarget') }}</div> <div v-for="(agent, idx) in profiles" :key="idx" @@ -126,7 +148,7 @@ <div class="agent-avatar">{{ (agent.username || 'A')[0] }}</div> <div class="agent-info"> <span class="agent-name">{{ agent.username }}</span> - <span class="agent-role">{{ agent.profession || '未知职业' }}</span> + <span class="agent-role">{{ describeAgentRole(agent) }}</span> </div> </div> </div> @@ -141,7 +163,7 @@ <path d="M9 11l3 3L22 4"></path> <path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path> </svg> - <span>发送问卷调查到世界中</span> + <span>{{ t('step5.sendSurvey') }}</span> </button> </div> </div> @@ -150,12 +172,12 @@ <div v-if="activeTab === 'chat'" class="chat-container"> <!-- Report Agent Tools Card --> - <div v-if="chatTarget === 'report_agent'" class="report-agent-tools-card"> + <div v-if="chatTarget === 'report_agent' && props.reportId" class="report-agent-tools-card"> <div class="tools-card-header"> <div class="tools-card-avatar">R</div> <div class="tools-card-info"> - <div class="tools-card-name">Report Agent - Chat</div> - <div class="tools-card-subtitle">报告生成智能体的快速对话版本,可调用 4 种专业工具,拥有MiroFish的完整记忆</div> + <div class="tools-card-name">{{ t('step5.reportAgentChatTitle') }}</div> + <div class="tools-card-subtitle">{{ t('step5.reportAgentChatSubtitle') }}</div> </div> <button class="tools-card-toggle" @click="showToolsDetail = !showToolsDetail"> <svg :class="{ 'is-expanded': showToolsDetail }" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"> @@ -172,8 +194,8 @@ </svg> </div> <div class="tool-content"> - <div class="tool-name">InsightForge 深度归因</div> - <div class="tool-desc">对齐现实世界种子数据与模拟环境状态,结合Global/Local Memory机制,提供跨时空的深度归因分析</div> + <div class="tool-name">{{ t('step5.tools.insightForge.name') }}</div> + <div class="tool-desc">{{ t('step5.tools.insightForge.desc') }}</div> </div> </div> <div class="tool-item tool-blue"> @@ -184,8 +206,8 @@ </svg> </div> <div class="tool-content"> - <div class="tool-name">PanoramaSearch 全景追踪</div> - <div class="tool-desc">基于图结构的广度遍历算法,重构事件传播路径,捕获全量信息流动的拓扑结构</div> + <div class="tool-name">{{ t('step5.tools.panoramaSearch.name') }}</div> + <div class="tool-desc">{{ t('step5.tools.panoramaSearch.desc') }}</div> </div> </div> <div class="tool-item tool-orange"> @@ -195,8 +217,8 @@ </svg> </div> <div class="tool-content"> - <div class="tool-name">QuickSearch 快速检索</div> - <div class="tool-desc">基于 GraphRAG 的即时查询接口,优化索引效率,用于快速提取具体的节点属性与离散事实</div> + <div class="tool-name">{{ t('step5.tools.quickSearch.name') }}</div> + <div class="tool-desc">{{ t('step5.tools.quickSearch.desc') }}</div> </div> </div> <div class="tool-item tool-green"> @@ -208,8 +230,8 @@ </svg> </div> <div class="tool-content"> - <div class="tool-name">InterviewSubAgent 虚拟访谈</div> - <div class="tool-desc">自主式访谈,能够并行与模拟世界中个体进行多轮对话,采集非结构化的观点数据与心理状态</div> + <div class="tool-name">{{ t('step5.tools.interviewSubAgent.name') }}</div> + <div class="tool-desc">{{ t('step5.tools.interviewSubAgent.desc') }}</div> </div> </div> </div> @@ -224,7 +246,7 @@ <div class="profile-card-name">{{ selectedAgent.username }}</div> <div class="profile-card-meta"> <span v-if="selectedAgent.name" class="profile-card-handle">@{{ selectedAgent.name }}</span> - <span class="profile-card-profession">{{ selectedAgent.profession || '未知职业' }}</span> + <span class="profile-card-profession">{{ describeAgentRole(selectedAgent) }}</span> </div> </div> <button class="profile-card-toggle" @click="showFullProfile = !showFullProfile"> @@ -235,7 +257,7 @@ </div> <div v-if="showFullProfile && selectedAgent.bio" class="profile-card-body"> <div class="profile-card-bio"> - <div class="profile-card-label">简介</div> + <div class="profile-card-label">{{ t('step5.profileBio') }}</div> <p>{{ selectedAgent.bio }}</p> </div> </div> @@ -250,7 +272,7 @@ </svg> </div> <p class="empty-text"> - {{ chatTarget === 'report_agent' ? '与 Report Agent 对话,深入了解报告内容' : '与模拟个体对话,了解他们的观点' }} + {{ chatTarget === 'report_agent' ? t('step5.emptyReportAgentChat') : t('step5.emptyAgentChat') }} </p> </div> <div @@ -266,7 +288,7 @@ <div class="message-content"> <div class="message-header"> <span class="sender-name"> - {{ msg.role === 'user' ? 'You' : (chatTarget === 'report_agent' ? 'Report Agent' : (selectedAgent?.username || 'Agent')) }} + {{ msg.role === 'user' ? t('step5.you') : (chatTarget === 'report_agent' ? t('step5.reportAgent') : (selectedAgent?.username || t('step5.agentFallback'))) }} </span> <span class="message-time">{{ formatTime(msg.timestamp) }}</span> </div> @@ -292,7 +314,7 @@ <textarea v-model="chatInput" class="chat-input" - placeholder="输入您的问题..." + :placeholder="t('step5.chatPlaceholder')" @keydown.enter.exact.prevent="sendMessage" :disabled="isSending || (!selectedAgent && chatTarget === 'agent')" rows="1" @@ -317,8 +339,8 @@ <div class="survey-setup"> <div class="setup-section"> <div class="section-header"> - <span class="section-title">选择调查对象</span> - <span class="selection-count">已选 {{ selectedAgents.size }} / {{ profiles.length }}</span> + <span class="section-title">{{ t('step5.selectSurveyTargets') }}</span> + <span class="selection-count">{{ t('step5.selectedCount', { selected: selectedAgents.size, total: profiles.length }) }}</span> </div> <div class="agents-grid"> <label @@ -335,7 +357,7 @@ <div class="checkbox-avatar">{{ (agent.username || 'A')[0] }}</div> <div class="checkbox-info"> <span class="checkbox-name">{{ agent.username }}</span> - <span class="checkbox-role">{{ agent.profession || '未知职业' }}</span> + <span class="checkbox-role">{{ describeAgentRole(agent) }}</span> </div> <div class="checkbox-indicator"> <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="3"> @@ -345,20 +367,20 @@ </label> </div> <div class="selection-actions"> - <button class="action-link" @click="selectAllAgents">全选</button> + <button class="action-link" @click="selectAllAgents">{{ t('step5.selectAll') }}</button> <span class="action-divider">|</span> - <button class="action-link" @click="clearAgentSelection">清空</button> + <button class="action-link" @click="clearAgentSelection">{{ t('step5.clear') }}</button> </div> </div> <div class="setup-section"> <div class="section-header"> - <span class="section-title">问卷问题</span> + <span class="section-title">{{ t('step5.surveyQuestion') }}</span> </div> <textarea v-model="surveyQuestion" class="survey-input" - placeholder="输入您想问所有被选中对象的问题..." + :placeholder="t('step5.surveyPlaceholder')" rows="3" ></textarea> </div> @@ -369,15 +391,15 @@ @click="submitSurvey" > <span v-if="isSurveying" class="loading-spinner"></span> - <span v-else>发送问卷</span> + <span v-else>{{ t('step5.sendSurvey') }}</span> </button> </div> <!-- Survey Results --> <div v-if="surveyResults.length > 0" class="survey-results"> <div class="results-header"> - <span class="results-title">调查结果</span> - <span class="results-count">{{ surveyResults.length }} 条回复</span> + <span class="results-title">{{ t('step5.surveyResults') }}</span> + <span class="results-count">{{ t('step5.replyCount', { count: surveyResults.length }) }}</span> </div> <div class="results-list"> <div @@ -389,7 +411,7 @@ <div class="result-avatar">{{ (result.agent_name || 'A')[0] }}</div> <div class="result-info"> <span class="result-name">{{ result.agent_name }}</span> - <span class="result-role">{{ result.profession || '未知职业' }}</span> + <span class="result-role">{{ result.platformLabel ? `${result.platformLabel} · ${result.profession || t('step5.unknownProfession')}` : (result.profession || t('step5.unknownProfession')) }}</span> </div> </div> <div class="result-question"> @@ -411,9 +433,25 @@ </template> <script setup> -import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue' +import { computed, ref, watch, onMounted, onUnmounted, nextTick } from 'vue' +import { useI18n } from 'vue-i18n' +import { useRouter } from 'vue-router' import { chatWithReport, getReport, getAgentLog } from '../api/report' -import { interviewAgents, getSimulationProfilesRealtime } from '../api/simulation' +import { interviewAgents, getEnvStatus, getSimulation, getSimulationProfilesRealtime } from '../api/simulation' +import { deriveInterviewTimeoutSeconds, resolveTimeoutMs } from '../api/timeout' +import { + buildInterviewRequest, + extractInterviewResponseContent, + formatInterviewFailureMessage, + formatAgentRole, + getEnabledProfilePlatforms, + getInterviewGuardMessage, + mergeInteractionProfiles, + summarizeInterviewTimeoutBudget, + summarizeInterviewEnvStatus, +} from './step5Profiles' +import { resolveReportReferenceValue } from './reportReferences.js' +import { getStep5RecoveryState } from './step5Recovery.js' const props = defineProps({ reportId: String, @@ -421,13 +459,19 @@ const props = defineProps({ }) const emit = defineEmits(['add-log', 'update-status']) +const { t, locale } = useI18n() +const router = useRouter() + +const resolvedReportReference = computed(() => + resolveReportReferenceValue(props.reportId, t('step4.unavailableId')) +) // State const activeTab = ref('chat') const chatTarget = ref('report_agent') const showAgentDropdown = ref(false) const selectedAgent = ref(null) -const selectedAgentIndex = ref(null) +const selectedAgentKey = ref(null) const showFullProfile = ref(true) const showToolsDetail = ref(true) @@ -444,6 +488,9 @@ const selectedAgents = ref(new Set()) const surveyQuestion = ref('') const surveyResults = ref([]) const isSurveying = ref(false) +const configuredApiTimeoutMs = resolveTimeoutMs(import.meta.env.VITE_API_TIMEOUT) +const interviewEnvStatus = ref(null) +const simulationData = ref(null) // Report Data const reportOutline = ref(null) @@ -452,6 +499,11 @@ const collapsedSections = ref(new Set()) const currentSectionIndex = ref(null) const profiles = ref([]) +const interviewTimeoutSeconds = (count = 1) => deriveInterviewTimeoutSeconds({ + requestTimeoutMs: configuredApiTimeoutMs, + interviewsCount: count, +}) + // Helper Methods const isSectionCompleted = (sectionIndex) => { return !!generatedSections.value[sectionIndex] @@ -490,19 +542,23 @@ const saveChatHistory = () => { if (chatTarget.value === 'report_agent') { chatHistoryCache.value['report_agent'] = [...chatHistory.value] - } else if (selectedAgentIndex.value !== null) { - chatHistoryCache.value[`agent_${selectedAgentIndex.value}`] = [...chatHistory.value] + } else if (selectedAgentKey.value) { + chatHistoryCache.value[`agent_${selectedAgentKey.value}`] = [...chatHistory.value] } } const selectReportAgentChat = () => { + if (!props.reportId) { + return + } + // 保存当前对话记录 saveChatHistory() activeTab.value = 'chat' chatTarget.value = 'report_agent' selectedAgent.value = null - selectedAgentIndex.value = null + selectedAgentKey.value = null showAgentDropdown.value = false // 恢复 Report Agent 的对话记录 @@ -512,7 +568,7 @@ const selectReportAgentChat = () => { const selectSurveyTab = () => { activeTab.value = 'survey' selectedAgent.value = null - selectedAgentIndex.value = null + selectedAgentKey.value = null showAgentDropdown.value = false } @@ -524,24 +580,71 @@ const toggleAgentDropdown = () => { } } -const selectAgent = (agent, idx) => { +const selectAgent = (agent) => { // 保存当前对话记录 saveChatHistory() selectedAgent.value = agent - selectedAgentIndex.value = idx + selectedAgentKey.value = agent.profileKey chatTarget.value = 'agent' showAgentDropdown.value = false // 恢复该 Agent 的对话记录 - chatHistory.value = chatHistoryCache.value[`agent_${idx}`] || [] - addLog(`选择对话对象: ${agent.username}`) + chatHistory.value = chatHistoryCache.value[`agent_${agent.profileKey}`] || [] + addLog(t('step5.logs.selectedChatTarget', { name: agent.username })) +} + +const describeAgentRole = (agent) => formatAgentRole(agent, t('step5.unknownProfession'), t) +const getInterviewStatusMessage = () => summarizeInterviewEnvStatus(interviewEnvStatus.value, t) +const getInterviewTimeoutHint = () => + summarizeInterviewTimeoutBudget({ + requestTimeoutMs: configuredApiTimeoutMs, + selectedCount: activeTab.value === 'survey' ? selectedAgents.value.size : 0, + t, + }) +const step3RecoveryState = computed(() => + getStep5RecoveryState({ + simulation: simulationData.value, + envStatus: interviewEnvStatus.value, + }) +) + +const refreshInterviewEnvStatus = async () => { + if (!props.simulationId) { + interviewEnvStatus.value = null + return null + } + + const response = await getEnvStatus({ simulation_id: props.simulationId }) + if (!response.success) { + throw new Error(response.error || t('step5.requestFailed')) + } + + interviewEnvStatus.value = response.data || null + return interviewEnvStatus.value +} + +const ensureInterviewReady = async (profilesToCheck = []) => { + const envStatus = await refreshInterviewEnvStatus() + const guardMessage = getInterviewGuardMessage(envStatus, profilesToCheck, t) + if (guardMessage) { + throw new Error(guardMessage) + } +} + +const openSavedStep3Run = () => { + if (!step3RecoveryState.value) { + return + } + + addLog(t('step5.logs.reopenStep3')) + router.push(step3RecoveryState.value.route) } const formatTime = (timestamp) => { if (!timestamp) return '' try { - return new Date(timestamp).toLocaleTimeString('en-US', { + return new Date(timestamp).toLocaleTimeString(locale.value === 'zh' ? 'zh-CN' : 'en-US', { hour12: false, hour: '2-digit', minute: '2-digit' @@ -662,10 +765,13 @@ const sendMessage = async () => { await sendToAgent(message) } } catch (err) { - addLog(`发送失败: ${err.message}`) + const formattedMessage = chatTarget.value === 'agent' + ? formatInterviewFailureMessage(err.message, t) + : (err.message || t('step5.requestFailed')) + addLog(t('step5.logs.sendFailed', { message: formattedMessage })) chatHistory.value.push({ role: 'assistant', - content: `抱歉,发生了错误: ${err.message}`, + content: t('step5.chatError', { message: formattedMessage }), timestamp: new Date().toISOString() }) } finally { @@ -677,7 +783,7 @@ const sendMessage = async () => { } const sendToReportAgent = async (message) => { - addLog(`向 Report Agent 发送: ${message.substring(0, 50)}...`) + addLog(t('step5.logs.sentToReportAgent', { message: message.substring(0, 50) })) // Build chat history for API const historyForApi = chatHistory.value @@ -697,21 +803,23 @@ const sendToReportAgent = async (message) => { if (res.success && res.data) { chatHistory.value.push({ role: 'assistant', - content: res.data.response || res.data.answer || '无响应', + content: res.data.response || res.data.answer || t('step5.noResponse'), timestamp: new Date().toISOString() }) - addLog('Report Agent 已回复') + addLog(t('step5.logs.reportAgentReplied')) } else { - throw new Error(res.error || '请求失败') + throw new Error(res.error || t('step5.requestFailed')) } } const sendToAgent = async (message) => { - if (!selectedAgent.value || selectedAgentIndex.value === null) { - throw new Error('请先选择一个模拟个体') + if (!selectedAgent.value || !selectedAgentKey.value) { + throw new Error(t('step5.selectAgentFirst')) } + + await ensureInterviewReady([selectedAgent.value]) - addLog(`向 ${selectedAgent.value.username} 发送: ${message.substring(0, 50)}...`) + addLog(t('step5.logs.sentToAgent', { name: selectedAgent.value.username, message: message.substring(0, 50) })) // Build prompt with chat history let prompt = message @@ -719,54 +827,32 @@ const sendToAgent = async (message) => { const historyContext = chatHistory.value .filter(msg => msg.content !== message) .slice(-6) - .map(msg => `${msg.role === 'user' ? '提问者' : '你'}:${msg.content}`) + .map(msg => `${msg.role === 'user' ? t('step5.historyQuestioner') : t('step5.historyYou')}:${msg.content}`) .join('\n') - prompt = `以下是我们之前的对话:\n${historyContext}\n\n现在我的新问题是:${message}` + prompt = t('step5.historyPrompt', { history: historyContext, message }) } const res = await interviewAgents({ simulation_id: props.simulationId, - interviews: [{ - agent_id: selectedAgentIndex.value, - prompt: prompt - }] + interviews: [buildInterviewRequest(selectedAgent.value, prompt)], + timeout: interviewTimeoutSeconds(1), }) if (res.success && res.data) { - // 正确的数据路径: res.data.result.results 是一个对象字典 - // 格式: {"twitter_0": {...}, "reddit_0": {...}} 或单平台 {"reddit_0": {...}} - const resultData = res.data.result || res.data - const resultsDict = resultData.results || resultData - - // 将对象字典转换为数组,优先获取 reddit 平台的回复 - let responseContent = null - const agentId = selectedAgentIndex.value - - if (typeof resultsDict === 'object' && !Array.isArray(resultsDict)) { - // 优先使用 reddit 平台回复,其次 twitter - const redditKey = `reddit_${agentId}` - const twitterKey = `twitter_${agentId}` - const agentResult = resultsDict[redditKey] || resultsDict[twitterKey] || Object.values(resultsDict)[0] - if (agentResult) { - responseContent = agentResult.response || agentResult.answer - } - } else if (Array.isArray(resultsDict) && resultsDict.length > 0) { - // 兼容数组格式 - responseContent = resultsDict[0].response || resultsDict[0].answer - } - + const responseContent = extractInterviewResponseContent(res.data, selectedAgent.value) + if (responseContent) { chatHistory.value.push({ role: 'assistant', content: responseContent, timestamp: new Date().toISOString() }) - addLog(`${selectedAgent.value.username} 已回复`) + addLog(t('step5.logs.agentReplied', { name: selectedAgent.value.username })) } else { - throw new Error('无响应数据') + throw new Error(t('step5.noResponseData')) } } else { - throw new Error(res.error || '请求失败') + throw new Error(res.error || t('step5.requestFailed')) } } @@ -803,66 +889,44 @@ const submitSurvey = async () => { if (selectedAgents.value.size === 0 || !surveyQuestion.value.trim()) return isSurveying.value = true - addLog(`发送问卷给 ${selectedAgents.value.size} 个对象...`) + addLog(t('step5.logs.surveySent', { count: selectedAgents.value.size })) try { - const interviews = Array.from(selectedAgents.value).map(idx => ({ - agent_id: idx, - prompt: surveyQuestion.value.trim() - })) + const selectedProfiles = Array.from(selectedAgents.value) + .map((idx) => profiles.value[idx]) + .filter(Boolean) + await ensureInterviewReady(selectedProfiles) + const interviews = selectedProfiles.map((agent) => buildInterviewRequest(agent, surveyQuestion.value.trim())) const res = await interviewAgents({ simulation_id: props.simulationId, - interviews: interviews + interviews: interviews, + timeout: interviewTimeoutSeconds(interviews.length), }) if (res.success && res.data) { - // 正确的数据路径: res.data.result.results 是一个对象字典 - // 格式: {"twitter_0": {...}, "reddit_0": {...}, "twitter_1": {...}, ...} - const resultData = res.data.result || res.data - const resultsDict = resultData.results || resultData - - // 将对象字典转换为数组格式 const surveyResultsList = [] - for (const interview of interviews) { - const agentIdx = interview.agent_id - const agent = profiles.value[agentIdx] - - // 优先使用 reddit 平台回复,其次 twitter - let responseContent = '无响应' - - if (typeof resultsDict === 'object' && !Array.isArray(resultsDict)) { - const redditKey = `reddit_${agentIdx}` - const twitterKey = `twitter_${agentIdx}` - const agentResult = resultsDict[redditKey] || resultsDict[twitterKey] - if (agentResult) { - responseContent = agentResult.response || agentResult.answer || '无响应' - } - } else if (Array.isArray(resultsDict)) { - // 兼容数组格式 - const matchedResult = resultsDict.find(r => r.agent_id === agentIdx) - if (matchedResult) { - responseContent = matchedResult.response || matchedResult.answer || '无响应' - } - } - + for (const agent of selectedProfiles) { + const responseContent = extractInterviewResponseContent(res.data, agent) || t('step5.noResponse') + surveyResultsList.push({ - agent_id: agentIdx, - agent_name: agent?.username || `Agent ${agentIdx}`, + agent_id: agent.agent_id, + agent_name: agent?.username || `Agent ${agent.agent_id}`, profession: agent?.profession, + platformLabel: agent?.platformLabel, question: surveyQuestion.value.trim(), answer: responseContent }) } surveyResults.value = surveyResultsList - addLog(`收到 ${surveyResults.value.length} 条回复`) + addLog(t('step5.logs.receivedReplies', { count: surveyResults.value.length })) } else { - throw new Error(res.error || '请求失败') + throw new Error(res.error || t('step5.requestFailed')) } } catch (err) { - addLog(`问卷发送失败: ${err.message}`) + addLog(t('step5.logs.surveyFailed', { message: formatInterviewFailureMessage(err.message, t) })) } finally { isSurveying.value = false } @@ -873,7 +937,7 @@ const loadReportData = async () => { if (!props.reportId) return try { - addLog(`加载报告数据: ${props.reportId}`) + addLog(t('step5.logs.loadingReport', { id: props.reportId })) // Get report info const reportRes = await getReport(props.reportId) @@ -882,7 +946,7 @@ const loadReportData = async () => { await loadAgentLogs() } } catch (err) { - addLog(`加载报告失败: ${err.message}`) + addLog(t('step5.logs.loadReportFailed', { message: err.message })) } } @@ -904,10 +968,10 @@ const loadAgentLogs = async () => { } }) - addLog('报告数据加载完成') + addLog(t('step5.logs.reportLoaded')) } } catch (err) { - addLog(`加载报告日志失败: ${err.message}`) + addLog(t('step5.logs.loadReportLogsFailed', { message: err.message })) } } @@ -915,13 +979,32 @@ const loadProfiles = async () => { if (!props.simulationId) return try { - const res = await getSimulationProfilesRealtime(props.simulationId, 'reddit') - if (res.success && res.data) { - profiles.value = res.data.profiles || [] - addLog(`加载了 ${profiles.value.length} 个模拟个体`) - } + const simulationResponse = await getSimulation(props.simulationId) + simulationData.value = simulationResponse?.success ? (simulationResponse.data || null) : null + const platforms = simulationResponse?.success + ? getEnabledProfilePlatforms(simulationResponse.data) + : ['reddit', 'twitter'] + const results = await Promise.allSettled( + platforms.map((platform) => getSimulationProfilesRealtime(props.simulationId, platform)) + ) + + const availableProfiles = results.flatMap((result, index) => { + const platform = platforms[index] + if (result.status !== 'fulfilled' || !result.value?.success || !result.value?.data?.profiles?.length) { + return [] + } + + return [{ + platform, + profiles: result.value.data.profiles, + }] + }) + + profiles.value = mergeInteractionProfiles(availableProfiles) + addLog(t('step5.logs.loadedAgents', { count: profiles.value.length })) } catch (err) { - addLog(`加载模拟个体失败: ${err.message}`) + simulationData.value = null + addLog(t('step5.logs.loadAgentsFailed', { message: err.message })) } } @@ -935,9 +1018,16 @@ const handleClickOutside = (e) => { // Lifecycle onMounted(() => { - addLog('Step5 深度互动初始化') + addLog(t('step5.logs.init')) + if (!props.reportId) { + activeTab.value = 'chat' + chatTarget.value = 'agent' + } loadReportData() loadProfiles() + refreshInterviewEnvStatus().catch((err) => { + addLog(t('step5.logs.envStatusFailed', { message: err.message })) + }) document.addEventListener('click', handleClickOutside) }) @@ -954,6 +1044,9 @@ watch(() => props.reportId, (newId) => { watch(() => props.simulationId, (newId) => { if (newId) { loadProfiles() + refreshInterviewEnvStatus().catch((err) => { + addLog(t('step5.logs.envStatusFailed', { message: err.message })) + }) } }, { immediate: true }) </script> @@ -1313,6 +1406,7 @@ watch(() => props.simulationId, (newId) => { display: flex; align-items: center; justify-content: space-between; + flex-wrap: wrap; padding: 14px 20px; border-bottom: 1px solid #E5E7EB; background: linear-gradient(180deg, #FFFFFF 0%, #FAFBFC 100%); @@ -1361,6 +1455,84 @@ watch(() => props.simulationId, (newId) => { justify-content: flex-end; } +.interview-status-banner { + flex-basis: 100%; + margin-top: -6px; + padding: 10px 12px; + border-radius: 10px; + background: #ECFDF5; + border: 1px solid #A7F3D0; + color: #065F46; + font-size: 12px; + line-height: 1.5; +} + +.interview-status-banner.warning { + background: #FEF3C7; + border-color: #FCD34D; + color: #92400E; +} + +.step3-recovery-card { + flex-basis: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-top: -6px; + padding: 12px 14px; + border-radius: 12px; + background: #FFF7ED; + border: 1px solid #FDBA74; +} + +.step3-recovery-copy { + min-width: 0; +} + +.step3-recovery-label { + display: block; + margin-bottom: 4px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #9A3412; +} + +.step3-recovery-text { + margin: 0; + color: #7C2D12; + font-size: 12px; + line-height: 1.5; +} + +.step3-recovery-btn { + flex-shrink: 0; + padding: 9px 14px; + border: none; + border-radius: 999px; + background: #1F2937; + color: #FFFFFF; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s ease, transform 0.2s ease; +} + +.step3-recovery-btn:hover { + background: #111827; + transform: translateY(-1px); +} + +.interview-timeout-hint { + flex-basis: 100%; + margin-top: -8px; + color: #6B7280; + font-size: 12px; + line-height: 1.5; +} + .tab-pill { display: flex; align-items: center; diff --git a/frontend/src/components/apiConfigDiagnostics.js b/frontend/src/components/apiConfigDiagnostics.js new file mode 100644 index 00000000..3a06dba8 --- /dev/null +++ b/frontend/src/components/apiConfigDiagnostics.js @@ -0,0 +1,130 @@ +export const buildBackendDiagnosticModel = (payload, t) => { + const none = t('common.none') + const summary = payload?.summary || {} + const validation = payload?.validation || {} + const llm = summary.llm || {} + const capabilities = summary.capabilities || {} + const sources = llm.sources || {} + const validationErrors = Array.isArray(validation.errors) ? validation.errors : [] + const usesOpenAIAliases = Boolean(sources.uses_openai_aliases) + const usesProjectAliases = Boolean(sources.uses_project_aliases) + const baseUrlConflict = sources.base_url_conflict || null + const hasBaseUrlConflict = Boolean(baseUrlConflict?.has_conflict) + const hasZepMissingError = validationErrors.some((message) => + typeof message === 'string' && message.includes('ZEP_API_KEY'), + ) + const llmBlockingErrors = validationErrors.filter((message) => + !(typeof message === 'string' && message.includes('ZEP_API_KEY')), + ) + const isConfigured = llm.configured && llmBlockingErrors.length === 0 && !hasBaseUrlConflict + const directLlmReady = Boolean(capabilities.direct_llm?.ready) + const graphBuildReady = Boolean(capabilities.graph_build?.ready) + const reportToolsReady = Boolean(capabilities.graph_report_tools?.ready) + const step5Ready = Boolean(capabilities.existing_simulation_interaction?.ready) + + let resolvedSource = t('apiConfig.diagnostics.sourceUnknown') + if (usesOpenAIAliases && usesProjectAliases) { + resolvedSource = t('apiConfig.diagnostics.sourceMixedAliases') + } else if (usesOpenAIAliases) { + resolvedSource = t('apiConfig.diagnostics.sourceOpenAIAliases') + } else if (usesProjectAliases) { + resolvedSource = t('apiConfig.diagnostics.sourceProjectAliases') + } + + const resolvedEnvVars = [ + sources.api_key_env, + sources.base_url_env, + sources.model_env, + ].filter(Boolean).join(' / ') || none + + const capabilityValue = (capability) => { + if (!capability || typeof capability !== 'object') { + return none + } + if (capability.ready) { + if (capability.requires_existing_simulation) { + return t('apiConfig.diagnostics.capabilityReadyExistingSimulation') + } + return t('apiConfig.diagnostics.capabilityReady') + } + if (capability.requires_zep) { + return t('apiConfig.diagnostics.capabilityNeedsZep') + } + if (capability.requires_existing_simulation) { + return t('apiConfig.diagnostics.capabilityNeedsExistingSimulation') + } + return t('apiConfig.diagnostics.capabilityNeedsBackendConfig') + } + + const nextSteps = [] + if (directLlmReady && hasZepMissingError && !graphBuildReady && !reportToolsReady) { + nextSteps.push(t('apiConfig.diagnostics.nextStepOpenStep2')) + if (step5Ready) { + nextSteps.push(t('apiConfig.diagnostics.nextStepReuseStep5')) + } + nextSteps.push(t('apiConfig.diagnostics.nextStepWaitForNonZep')) + } + + return { + tone: isConfigured ? 'ready' : 'warning', + headline: hasBaseUrlConflict + ? t('apiConfig.diagnostics.baseUrlConflictTitle') + : isConfigured + ? (usesOpenAIAliases + ? t('apiConfig.diagnostics.configuredOpenAI') + : t('apiConfig.diagnostics.configured')) + : t('apiConfig.diagnostics.incomplete'), + note: hasBaseUrlConflict + ? t('apiConfig.diagnostics.baseUrlConflictNote', { + selectedEnv: baseUrlConflict.selected_env || none, + selectedValue: baseUrlConflict.selected_value || none, + configuredEnvNames: (baseUrlConflict.configured_envs || []) + .map((entry) => entry.name) + .join(' / ') || none, + }) + : isConfigured && hasZepMissingError + ? t('apiConfig.diagnostics.zepMissingNote') + : '', + nextSteps, + rows: [ + { + label: t('apiConfig.diagnostics.modeLabel'), + value: llm.backend_mode === 'openai_compatible' + ? t('apiConfig.diagnostics.modeOpenAICompatible') + : (llm.backend_mode || none), + }, + { + label: t('apiConfig.diagnostics.sourceLabel'), + value: resolvedSource, + }, + { + label: t('apiConfig.diagnostics.envLabel'), + value: resolvedEnvVars, + }, + { + label: t('apiConfig.diagnostics.baseUrlLabel'), + value: llm.base_url || none, + }, + { + label: t('apiConfig.diagnostics.modelLabel'), + value: llm.model || none, + }, + { + label: t('apiConfig.diagnostics.directLlmLabel'), + value: capabilityValue(capabilities.direct_llm), + }, + { + label: t('apiConfig.diagnostics.graphBuildLabel'), + value: capabilityValue(capabilities.graph_build), + }, + { + label: t('apiConfig.diagnostics.reportToolsLabel'), + value: capabilityValue(capabilities.graph_report_tools), + }, + { + label: t('apiConfig.diagnostics.step5Label'), + value: capabilityValue(capabilities.existing_simulation_interaction), + }, + ], + } +} diff --git a/frontend/src/components/graphAliasDetails.js b/frontend/src/components/graphAliasDetails.js new file mode 100644 index 00000000..1b5dce1f --- /dev/null +++ b/frontend/src/components/graphAliasDetails.js @@ -0,0 +1,14 @@ +const uniqueValues = (values) => [...new Set((values || []).filter(Boolean))] + +export const getDisplayedAliasNames = (node) => { + if (!node) { + return [] + } + + const canonicalName = typeof node.name === 'string' ? node.name.trim() : '' + const aliases = uniqueValues(node.alias_names) + + return aliases.filter((alias) => alias !== canonicalName) +} + +export const hasMergedAliases = (node) => getDisplayedAliasNames(node).length > 0 diff --git a/frontend/src/components/graphPanelData.js b/frontend/src/components/graphPanelData.js new file mode 100644 index 00000000..ce970179 --- /dev/null +++ b/frontend/src/components/graphPanelData.js @@ -0,0 +1,53 @@ +import { mapProcessGraphData } from '../views/processGraphData.js' + +const ENTITY_TYPE_COLORS = ['#FF6B35', '#004E89', '#7B2D8E', '#1A936F', '#C5283D', '#E9724C', '#3498db', '#9b59b6', '#27ae60', '#f39c12'] + +export const normalizeGraphPanelData = ({ + graphData, + unnamedNodeLabel = 'Unnamed', + unknownNodeLabel = 'Unknown', +} = {}) => { + if (!graphData) { + return { + nodes: [], + edges: [], + entityTypes: [], + } + } + + const mapped = mapProcessGraphData({ + nodes: graphData.nodes || [], + edges: graphData.edges || [], + unnamedNodeLabel, + unknownNodeLabel, + }) + + const typeMap = {} + mapped.nodes.forEach((node) => { + const type = node.type || 'Entity' + if (!typeMap[type]) { + typeMap[type] = { + name: type, + count: 0, + color: ENTITY_TYPE_COLORS[Object.keys(typeMap).length % ENTITY_TYPE_COLORS.length], + } + } + typeMap[type].count += 1 + }) + + return { + nodes: mapped.nodes, + edges: mapped.edges, + entityTypes: Object.values(typeMap), + } +} + +export const summarizeGraphData = (options = {}) => { + const normalized = normalizeGraphPanelData(options) + + return { + nodeCount: normalized.nodes.length, + edgeCount: normalized.edges.length, + entityTypes: normalized.entityTypes, + } +} diff --git a/frontend/src/components/historyFormatters.js b/frontend/src/components/historyFormatters.js new file mode 100644 index 00000000..3124ba74 --- /dev/null +++ b/frontend/src/components/historyFormatters.js @@ -0,0 +1,14 @@ +export const truncateFilename = (filename, maxLength, unknownLabel = 'Unknown file') => { + if (!filename) { + return unknownLabel + } + + if (filename.length <= maxLength) { + return filename + } + + const ext = filename.includes('.') ? `.${filename.split('.').pop()}` : '' + const nameWithoutExt = filename.slice(0, filename.length - ext.length) + const truncatedName = `${nameWithoutExt.slice(0, maxLength - ext.length - 3)}...` + return truncatedName + ext +} diff --git a/frontend/src/components/historyPlayback.js b/frontend/src/components/historyPlayback.js new file mode 100644 index 00000000..97bee8f5 --- /dev/null +++ b/frontend/src/components/historyPlayback.js @@ -0,0 +1,17 @@ +export const hasReplayableSimulationState = (simulation = {}) => { + const runnerStatus = simulation.runner_status || 'idle' + const currentRound = Number(simulation.current_round || 0) + const totalRounds = Number(simulation.total_rounds || 0) + + if (['running', 'starting', 'completed', 'stopped', 'failed', 'stopping', 'paused'].includes(runnerStatus)) { + return true + } + + return currentRound > 0 || totalRounds > 0 +} + +export const buildSimulationReplayRoute = (simulationId) => ({ + name: 'SimulationRun', + params: { simulationId }, + query: { replay: '1' }, +}) diff --git a/frontend/src/components/historyReportDownload.js b/frontend/src/components/historyReportDownload.js new file mode 100644 index 00000000..88463647 --- /dev/null +++ b/frontend/src/components/historyReportDownload.js @@ -0,0 +1,61 @@ +const trimTrailingSlash = (value) => (value || '').replace(/\/+$/, '') + +const sanitizeFilenamePart = (value) => + (value || '') + .trim() + .replace(/[^a-zA-Z0-9._-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + +export const buildHistoryReportDownloadUrl = (reportId, baseURL = '') => { + if (!reportId) { + return '' + } + + return `${trimTrailingSlash(baseURL)}/api/report/${encodeURIComponent(reportId)}/download` +} + +export const buildHistoryReportDownloadFilename = (reportId, simulationId) => { + const reportPart = sanitizeFilenamePart(reportId) + if (!reportPart) { + return '' + } + + const simulationPart = sanitizeFilenamePart(simulationId) + return simulationPart + ? `mirofish-report-${reportPart}--simulation-${simulationPart}.md` + : `mirofish-report-${reportPart}.md` +} + +export const triggerHistoryReportDownload = ( + reportId, + { + simulationId = '', + baseURL = '', + documentRef = typeof document !== 'undefined' ? document : null, + } = {} +) => { + const href = buildHistoryReportDownloadUrl(reportId, baseURL) + const downloadName = buildHistoryReportDownloadFilename(reportId, simulationId) + if (!href || !documentRef?.createElement) { + return false + } + + const anchor = documentRef.createElement('a') + anchor.href = href + anchor.download = downloadName || `${reportId}.md` + anchor.rel = 'noopener' + anchor.style.display = 'none' + + const parent = documentRef.body || documentRef.documentElement + parent?.appendChild?.(anchor) + anchor.click() + + if (typeof anchor.remove === 'function') { + anchor.remove() + } else { + parent?.removeChild?.(anchor) + } + + return true +} diff --git a/frontend/src/components/interactionRoute.js b/frontend/src/components/interactionRoute.js new file mode 100644 index 00000000..2dfac47a --- /dev/null +++ b/frontend/src/components/interactionRoute.js @@ -0,0 +1,17 @@ +export const buildInteractionRoute = ({ reportId = '', simulationId = '' } = {}) => { + if (typeof reportId === 'string' && reportId.trim()) { + return { + name: 'Interaction', + params: { reportId: reportId.trim() }, + } + } + + if (typeof simulationId === 'string' && simulationId.trim()) { + return { + name: 'InteractionSimulation', + params: { simulationId: simulationId.trim() }, + } + } + + return null +} diff --git a/frontend/src/components/liveActionBuffer.js b/frontend/src/components/liveActionBuffer.js new file mode 100644 index 00000000..532e3327 --- /dev/null +++ b/frontend/src/components/liveActionBuffer.js @@ -0,0 +1,45 @@ +export const DEFAULT_MAX_TIMELINE_ACTIONS = 1000 + +export const buildLiveActionId = (action = {}) => + action.id || `${action.timestamp}-${action.platform}-${action.agent_id}-${action.action_type}` + +export const mergeLiveActions = ({ + existingActions = [], + existingIds = new Set(), + incomingActions = [], + latestActionTimestamp = '', + maxActions = DEFAULT_MAX_TIMELINE_ACTIONS, +} = {}) => { + const nextActions = [...existingActions] + const nextIds = new Set(existingIds) + let nextLatestTimestamp = latestActionTimestamp + + incomingActions.forEach((action) => { + const actionId = buildLiveActionId(action) + + if (!nextIds.has(actionId)) { + nextIds.add(actionId) + nextActions.push({ + ...action, + _uniqueId: actionId, + }) + } + + if (action.timestamp && action.timestamp > nextLatestTimestamp) { + nextLatestTimestamp = action.timestamp + } + }) + + if (maxActions > 0 && nextActions.length > maxActions) { + const removedActions = nextActions.splice(0, nextActions.length - maxActions) + removedActions.forEach((action) => { + nextIds.delete(action._uniqueId || buildLiveActionId(action)) + }) + } + + return { + actions: nextActions, + actionIds: nextIds, + latestActionTimestamp: nextLatestTimestamp, + } +} diff --git a/frontend/src/components/reportCapability.js b/frontend/src/components/reportCapability.js new file mode 100644 index 00000000..675e37c2 --- /dev/null +++ b/frontend/src/components/reportCapability.js @@ -0,0 +1,25 @@ +export const getReportPreflightBlockReason = (payload, t) => { + if (!t) { + return '' + } + + const capabilities = payload?.summary?.capabilities || payload?.data?.summary?.capabilities + if (!capabilities || typeof capabilities !== 'object') { + return '' + } + + const reportTools = capabilities.graph_report_tools + if (!reportTools || reportTools.ready !== false) { + return '' + } + + if (reportTools.requires_zep && capabilities.direct_llm?.ready) { + return t('apiConfig.diagnostics.zepMissingNote') + } + + if (reportTools.requires_existing_simulation) { + return t('apiConfig.diagnostics.capabilityNeedsExistingSimulation') + } + + return t('apiConfig.diagnostics.capabilityNeedsBackendConfig') +} diff --git a/frontend/src/components/reportParsers.js b/frontend/src/components/reportParsers.js new file mode 100644 index 00000000..8ffdca28 --- /dev/null +++ b/frontend/src/components/reportParsers.js @@ -0,0 +1,593 @@ +const NO_REPLY_MARKERS = new Set([ + '(该平台未获得回复)', + '(该平台未获得回复)', + '[无回复]', + '(no reply from this platform)', + '[no reply]', + 'no reply from this platform', +]) + +const INTERVIEW_REASON_SKIP_RE = /^(?:未选|综上|最终选择|not selected|overall|final selection)/i +const INTERVIEW_SPLIT_RE = /####\s*(?:采访|Interview)\s*#\d+:/i +const INTERVIEW_TOPIC_RE = /\*\*(?:采访主题|Interview Topic|Topic):\*\*\s*(.+?)(?:\n|$)/i +const INTERVIEW_COUNT_RE = /\*\*(?:采访人数|Interview(?:ed)? Agents?|Agents Interviewed):\*\*\s*(\d+)\s*\/\s*(\d+)/i +const INTERVIEW_REASON_RE = /###\s*(?:采访对象选择理由|Why These Interviewees|Interviewee Selection Rationale|Selection Rationale)\n([\s\S]*?)(?=\n---\n|\n###\s*(?:采访实录|Interview Transcript))/i +const INTERVIEW_BIO_RE = /_(?:简介|Bio|Profile):\s*([\s\S]*?)_\n/i +const INTERVIEW_SUMMARY_RE = /###\s*(?:采访摘要与核心观点|Interview Summary(?: and Key Takeaways)?|Key Takeaways)\n([\s\S]*?)$/i +const INTERVIEW_QUOTES_RE = /\*\*(?:关键引言|Key Quotes|Quotes):\*\*\n([\s\S]*?)(?=\n---|\n####|$)/i +const QUICK_QUERY_RE = /(?:搜索查询|Search Query):\s*(.+?)(?:\n|$)/i +const QUICK_COUNT_RE = /(?:找到|Found)\s*(\d+)\s*(?:条(?:相关(?:信息|事实))?|relevant (?:items|facts|results)?)/i +const QUICK_FACTS_RE = /###\s*(?:相关事实|Relevant Facts):\n([\s\S]*?)(?=\n###|$)/i +const QUICK_EDGES_RE = /###\s*(?:相关边|Related Edges):\n([\s\S]*?)(?=\n###|$)/i +const QUICK_NODES_RE = /###\s*(?:相关节点|Related Nodes):\n([\s\S]*?)(?=\n###|$)/i +const INSIGHT_QUERY_RE = /(?:分析问题|Analysis Question):\s*(.+?)(?:\n|$)/i +const INSIGHT_SCENARIO_RE = /(?:预测场景|Prediction Scenario):\s*(.+?)(?:\n|$)/i +const INSIGHT_FACT_COUNT_RE = /(?:相关预测事实|Relevant Prediction Facts):\s*(\d+)/i +const INSIGHT_ENTITY_COUNT_RE = /(?:涉及实体|Entities Involved):\s*(\d+)/i +const INSIGHT_RELATION_COUNT_RE = /(?:关系链|Relationship Chains):\s*(\d+)/i +const INSIGHT_SUBQUERIES_RE = /###\s*(?:分析的子问题|Analysis Subquestions)\n([\s\S]*?)(?=\n###|$)/i +const INSIGHT_FACTS_RE = /###\s*(?:【关键事实】|Key Facts)\n([\s\S]*?)(?=\n###|$)/i +const INSIGHT_ENTITIES_RE = /###\s*(?:【核心实体】|Core Entities)\n([\s\S]*?)(?=\n###|$)/i +const INSIGHT_ENTITY_SUMMARY_RE = /(?:摘要|Summary):\s*"?(.+?)"?(?:\n|$)/i +const INSIGHT_ENTITY_RELATED_RE = /(?:相关事实|Related Facts):\s*(\d+)/i +const INSIGHT_RELATIONS_RE = /###\s*(?:【关系链】|Relationship Chains)\n([\s\S]*?)(?=\n###|$)/i +const PANORAMA_QUERY_RE = /(?:查询|Query):\s*(.+?)(?:\n|$)/i +const PANORAMA_NODES_RE = /(?:总节点数|Total Nodes):\s*(\d+)/i +const PANORAMA_EDGES_RE = /(?:总边数|Total Edges):\s*(\d+)/i +const PANORAMA_ACTIVE_COUNT_RE = /(?:当前有效事实|Current Active Facts):\s*(\d+)/i +const PANORAMA_HISTORICAL_COUNT_RE = /(?:历史\/过期事实|Historical\/Expired Facts):\s*(\d+)/i +const PANORAMA_ACTIVE_RE = /###\s*(?:【当前有效事实】|Current Active Facts)\n([\s\S]*?)(?=\n###|$)/i +const PANORAMA_HISTORICAL_RE = /###\s*(?:【历史\/过期事实】|Historical\/Expired Facts)\n([\s\S]*?)(?=\n###|$)/i +const PANORAMA_ENTITIES_RE = /###\s*(?:【涉及实体】|Entities Involved)\n([\s\S]*?)(?=\n###|$)/i +const QUESTION_PREFIX_RE = /(?:^|[\r\n]+)(?:问题|Question)\s*(\d+)[::]\s*/g +const NUMBERED_PREFIX_RE = /(?:^|[\r\n]+)(\d+)\.\s+/g +const FINAL_ANSWER_RE = /Final\s*Answer:\s*\n*([\s\S]*)$/i +const CHINESE_FINAL_ANSWER_RE = /最终答案[::]\s*\n*([\s\S]*)$/i +const QUESTION_SECTION_RE = /\*\*(?:Q|Questions?):\*\*\s*([\s\S]*?)(?=\n\n\*\*(?:A|Answer):\*\*|\*\*(?:A|Answer):\*\*)/i +const ANSWER_SECTION_RE = /\*\*(?:A|Answer):\*\*\s*([\s\S]*?)(?=\*\*(?:关键引言|Key Quotes|Quotes)|$)/i +const QUESTION_PREFIX_SPLIT_RE = /(?:^|[\r\n]+)(?:问题|Question)\s*\d+[::]\s*/i +const TWITTER_SECTION_RE = /【Twitter(?:平台回答| Reply| Response| Answer)】\n?([\s\S]*?)(?=【Reddit(?:平台回答| Reply| Response| Answer)】|$)/i +const REDDIT_SECTION_RE = /【Reddit(?:平台回答| Reply| Response| Answer)】\n?([\s\S]*?)$/i + +export const isMissingPlatformReply = (text) => { + if (!text) { + return true + } + + return NO_REPLY_MARKERS.has(text.trim().toLowerCase()) +} + +const collectNumberedLines = (text) => + text + .split('\n') + .filter((line) => /^\d+\./.test(line.trim())) + .map((line) => line.replace(/^\d+\.\s*/, '').trim()) + .filter(Boolean) + +const parseIndividualReasons = (reasonText) => { + const reasons = {} + if (!reasonText) { + return reasons + } + + const lines = reasonText.split(/\n+/) + let currentName = null + let currentReason = [] + + for (const line of lines) { + let headerMatch = line.match(/^\d+\.\s*\*\*([^*((]+)(?:[((]index\s*=?\s*\d+[))])?\*\*[::]\s*(.*)/) + if (!headerMatch) { + headerMatch = line.match(/^-\s*(?:选择|Select)\s*([^((]+)(?:[((]index\s*=?\s*\d+[))])?[::]\s*(.*)/i) + } + if (!headerMatch) { + headerMatch = line.match(/^-\s*\*\*([^*((]+)(?:[((]index\s*=?\s*\d+[))])?\*\*[::]\s*(.*)/) + } + + if (headerMatch) { + if (currentName && currentReason.length > 0) { + reasons[currentName] = currentReason.join(' ').trim() + } + currentName = headerMatch[1].trim() + currentReason = headerMatch[2] ? [headerMatch[2].trim()] : [] + continue + } + + if (currentName && line.trim() && !INTERVIEW_REASON_SKIP_RE.test(line.trim())) { + currentReason.push(line.trim()) + } + } + + if (currentName && currentReason.length > 0) { + reasons[currentName] = currentReason.join(' ').trim() + } + + return reasons +} + +const splitQuestions = (questionText) => { + if (!questionText) { + return [] + } + + if (QUESTION_PREFIX_SPLIT_RE.test(questionText)) { + const questions = [] + const prefixRe = new RegExp(QUESTION_PREFIX_RE.source, QUESTION_PREFIX_RE.flags) + const matches = [...questionText.matchAll(prefixRe)] + + for (let index = 0; index < matches.length; index += 1) { + const current = matches[index] + const next = matches[index + 1] + const start = current.index + current[0].length + const end = next ? next.index : questionText.length + const question = questionText.slice(start, end).trim() + if (question) { + questions.push(question) + } + } + + if (questions.length > 0) { + return questions + } + } + + const questions = questionText.split(/\n\d+\.\s+/).filter((item) => item.trim()) + if (questions.length === 0) { + return [] + } + + const firstQuestion = questionText.match(/^1\.\s+(.+)/) + if (firstQuestion) { + return [firstQuestion[1].trim(), ...questions.slice(1).map((item) => item.trim())] + } + + return questions.map((item) => item.trim()) +} + +const splitAnswerByQuestions = (answerText) => { + if (!answerText || isMissingPlatformReply(answerText)) { + return [''] + } + + const matches = [] + let match = null + + while ((match = QUESTION_PREFIX_RE.exec(answerText)) !== null) { + matches.push({ + index: match.index, + fullMatch: match[0], + }) + } + + if (matches.length === 0) { + while ((match = NUMBERED_PREFIX_RE.exec(answerText)) !== null) { + matches.push({ + index: match.index, + fullMatch: match[0], + }) + } + } + + if (matches.length <= 1) { + const cleaned = answerText + .replace(/^(?:问题|Question)\s*\d+[::]\s*/i, '') + .replace(/^\d+\.\s+/, '') + .trim() + return [cleaned || answerText] + } + + const parts = [] + for (let index = 0; index < matches.length; index += 1) { + const current = matches[index] + const next = matches[index + 1] + const start = current.index + current.fullMatch.length + const end = next ? next.index : answerText.length + parts.push(answerText.substring(start, end).replace(/[\r\n]+$/, '').trim()) + } + + return parts.some(Boolean) ? parts : [answerText] +} + +export const getInterviewAnswerForQuestion = (interview, questionIndex, platform) => { + const answer = platform === 'twitter' + ? interview.twitterAnswer + : (interview.redditAnswer || interview.twitterAnswer) + + if (!answer || isMissingPlatformReply(answer)) { + return answer || '' + } + + const answers = splitAnswerByQuestions(answer) + if (answers.length > 1 && questionIndex < answers.length) { + return answers[questionIndex] || '' + } + + return questionIndex === 0 ? answer : '' +} + +export const parseInterview = (text) => { + const result = { + topic: '', + agentCount: '', + successCount: 0, + totalCount: 0, + selectionReason: '', + interviews: [], + summary: '', + } + + try { + const topicMatch = text.match(INTERVIEW_TOPIC_RE) + if (topicMatch) { + result.topic = topicMatch[1].trim() + } + + const countMatch = text.match(INTERVIEW_COUNT_RE) + if (countMatch) { + result.successCount = parseInt(countMatch[1], 10) + result.totalCount = parseInt(countMatch[2], 10) + result.agentCount = `${countMatch[1]} / ${countMatch[2]}` + } + + const reasonMatch = text.match(INTERVIEW_REASON_RE) + if (reasonMatch) { + result.selectionReason = reasonMatch[1].trim() + } + + const individualReasons = parseIndividualReasons(result.selectionReason) + const interviewBlocks = text.split(INTERVIEW_SPLIT_RE).slice(1) + + interviewBlocks.forEach((block, index) => { + const interview = { + num: index + 1, + title: '', + name: '', + role: '', + bio: '', + selectionReason: '', + questions: [], + twitterAnswer: '', + redditAnswer: '', + quotes: [], + } + + const titleMatch = block.match(/^(.+?)\n/) + if (titleMatch) { + interview.title = titleMatch[1].trim() + } + + const nameRoleMatch = block.match(/\*\*(.+?)\*\*\s*\((.+?)\)/) + if (nameRoleMatch) { + interview.name = nameRoleMatch[1].trim() + interview.role = nameRoleMatch[2].trim() + interview.selectionReason = individualReasons[interview.name] || '' + } + + const bioMatch = block.match(INTERVIEW_BIO_RE) + if (bioMatch) { + interview.bio = bioMatch[1].trim().replace(/\.\.\.$/, '...') + } + + const questionMatch = block.match(QUESTION_SECTION_RE) + if (questionMatch) { + interview.questions = splitQuestions(questionMatch[1].trim()) + } + + const answerMatch = block.match(ANSWER_SECTION_RE) + if (answerMatch) { + const answerText = answerMatch[1].trim() + const twitterMatch = answerText.match(TWITTER_SECTION_RE) + const redditMatch = answerText.match(REDDIT_SECTION_RE) + + if (twitterMatch) { + interview.twitterAnswer = twitterMatch[1].trim() + } + if (redditMatch) { + interview.redditAnswer = redditMatch[1].trim() + } + + if (!twitterMatch && redditMatch) { + if (!isMissingPlatformReply(interview.redditAnswer)) { + interview.twitterAnswer = interview.redditAnswer + } + } else if (twitterMatch && !redditMatch) { + if (!isMissingPlatformReply(interview.twitterAnswer)) { + interview.redditAnswer = interview.twitterAnswer + } + } else if (!twitterMatch && !redditMatch) { + interview.twitterAnswer = answerText + } + } + + const quotesMatch = block.match(INTERVIEW_QUOTES_RE) + if (quotesMatch) { + let quoteMatches = quotesMatch[1].match(/> "([^"]+)"/g) + if (!quoteMatches) { + quoteMatches = quotesMatch[1].match(/> [\u201C""]([^\u201D""]+)[\u201D""]/g) + } + if (quoteMatches) { + interview.quotes = quoteMatches + .map((quote) => quote.replace(/^> [\u201C""]|[\u201D""]$/g, '').trim()) + .filter(Boolean) + } + } + + if (interview.name || interview.title) { + result.interviews.push(interview) + } + }) + + const summaryMatch = text.match(INTERVIEW_SUMMARY_RE) + if (summaryMatch) { + result.summary = summaryMatch[1].trim() + } + } catch (error) { + console.warn('Parse interview failed:', error) + } + + return result +} + +export const parseQuickSearch = (text) => { + const result = { + query: '', + count: 0, + facts: [], + edges: [], + nodes: [], + } + + try { + const queryMatch = text.match(QUICK_QUERY_RE) + if (queryMatch) { + result.query = queryMatch[1].trim() + } + + const countMatch = text.match(QUICK_COUNT_RE) + if (countMatch) { + result.count = parseInt(countMatch[1], 10) + } + + const factsSection = text.match(QUICK_FACTS_RE) + if (factsSection) { + result.facts = collectNumberedLines(factsSection[1]) + } + + const edgesSection = text.match(QUICK_EDGES_RE) + if (edgesSection) { + result.edges = edgesSection[1] + .split('\n') + .filter((line) => line.trim().startsWith('-')) + .map((line) => { + const match = line.match(/^-\s*(.+?)\s*--\[(.+?)\]-->\s*(.+)$/) + if (!match) { + return null + } + return { + source: match[1].trim(), + relation: match[2].trim(), + target: match[3].trim(), + } + }) + .filter(Boolean) + } + + const nodesSection = text.match(QUICK_NODES_RE) + if (nodesSection) { + result.nodes = nodesSection[1] + .split('\n') + .filter((line) => line.trim().startsWith('-')) + .map((line) => { + const typedNode = line.match(/^-\s*\*\*(.+?)\*\*\s*\((.+?)\)/) + if (typedNode) { + return { name: typedNode[1].trim(), type: typedNode[2].trim() } + } + const simpleNode = line.match(/^-\s*(.+)$/) + if (simpleNode) { + return { name: simpleNode[1].trim(), type: '' } + } + return null + }) + .filter(Boolean) + } + } catch (error) { + console.warn('Parse quick_search failed:', error) + } + + return result +} + +export const parseInsightForge = (text) => { + const result = { + query: '', + simulationRequirement: '', + stats: { facts: 0, entities: 0, relationships: 0 }, + subQueries: [], + facts: [], + entities: [], + relations: [], + } + + try { + const queryMatch = text.match(INSIGHT_QUERY_RE) + if (queryMatch) { + result.query = queryMatch[1].trim() + } + + const scenarioMatch = text.match(INSIGHT_SCENARIO_RE) + if (scenarioMatch) { + result.simulationRequirement = scenarioMatch[1].trim() + } + + const factMatch = text.match(INSIGHT_FACT_COUNT_RE) + const entityMatch = text.match(INSIGHT_ENTITY_COUNT_RE) + const relationMatch = text.match(INSIGHT_RELATION_COUNT_RE) + if (factMatch) { + result.stats.facts = parseInt(factMatch[1], 10) + } + if (entityMatch) { + result.stats.entities = parseInt(entityMatch[1], 10) + } + if (relationMatch) { + result.stats.relationships = parseInt(relationMatch[1], 10) + } + + const subQueriesSection = text.match(INSIGHT_SUBQUERIES_RE) + if (subQueriesSection) { + result.subQueries = collectNumberedLines(subQueriesSection[1]) + } + + const factsSection = text.match(INSIGHT_FACTS_RE) + if (factsSection) { + result.facts = collectNumberedLines(factsSection[1]).map((line) => + line.replace(/^"|"$/g, '').trim() + ) + } + + const entitiesSection = text.match(INSIGHT_ENTITIES_RE) + if (entitiesSection) { + const entityBlocks = entitiesSection[1] + .split(/\n(?=- \*\*)/) + .filter((block) => block.trim().startsWith('- **')) + + result.entities = entityBlocks + .map((block) => { + const nameMatch = block.match(/^-\s*\*\*(.+?)\*\*\s*\((.+?)\)/) + return { + name: nameMatch ? nameMatch[1].trim() : '', + type: nameMatch ? nameMatch[2].trim() : '', + summary: block.match(INSIGHT_ENTITY_SUMMARY_RE)?.[1]?.trim() || '', + relatedFactsCount: parseInt(block.match(INSIGHT_ENTITY_RELATED_RE)?.[1] || '0', 10), + } + }) + .filter((entity) => entity.name) + } + + const relationsSection = text.match(INSIGHT_RELATIONS_RE) + if (relationsSection) { + result.relations = relationsSection[1] + .split('\n') + .filter((line) => line.trim().startsWith('-')) + .map((line) => { + const match = line.match(/^-\s*(.+?)\s*--\[(.+?)\]-->\s*(.+)$/) + if (!match) { + return null + } + return { + source: match[1].trim(), + relation: match[2].trim(), + target: match[3].trim(), + } + }) + .filter(Boolean) + } + } catch (error) { + console.warn('Parse insight_forge failed:', error) + } + + return result +} + +export const parsePanorama = (text) => { + const result = { + query: '', + stats: { nodes: 0, edges: 0, activeFacts: 0, historicalFacts: 0 }, + activeFacts: [], + historicalFacts: [], + entities: [], + } + + try { + const queryMatch = text.match(PANORAMA_QUERY_RE) + if (queryMatch) { + result.query = queryMatch[1].trim() + } + + const nodesMatch = text.match(PANORAMA_NODES_RE) + const edgesMatch = text.match(PANORAMA_EDGES_RE) + const activeMatch = text.match(PANORAMA_ACTIVE_COUNT_RE) + const historicalMatch = text.match(PANORAMA_HISTORICAL_COUNT_RE) + if (nodesMatch) { + result.stats.nodes = parseInt(nodesMatch[1], 10) + } + if (edgesMatch) { + result.stats.edges = parseInt(edgesMatch[1], 10) + } + if (activeMatch) { + result.stats.activeFacts = parseInt(activeMatch[1], 10) + } + if (historicalMatch) { + result.stats.historicalFacts = parseInt(historicalMatch[1], 10) + } + + const activeSection = text.match(PANORAMA_ACTIVE_RE) + if (activeSection) { + result.activeFacts = collectNumberedLines(activeSection[1]).map((line) => + line.replace(/^"|"$/g, '').trim() + ) + } + + const historicalSection = text.match(PANORAMA_HISTORICAL_RE) + if (historicalSection) { + result.historicalFacts = collectNumberedLines(historicalSection[1]).map((line) => + line.replace(/^"|"$/g, '').trim() + ) + } + + const entitiesSection = text.match(PANORAMA_ENTITIES_RE) + if (entitiesSection) { + result.entities = entitiesSection[1] + .split('\n') + .filter((line) => line.trim().startsWith('-')) + .map((line) => { + const typedNode = line.match(/^-\s*\*\*(.+?)\*\*\s*\((.+?)\)/) + if (!typedNode) { + return null + } + return { name: typedNode[1].trim(), type: typedNode[2].trim() } + }) + .filter(Boolean) + } + } catch (error) { + console.warn('Parse panorama failed:', error) + } + + return result +} + +export const extractFinalContent = (response) => { + if (!response) { + return null + } + + const finalAnswerTagMatch = response.match(/<final_answer>([\s\S]*?)<\/final_answer>/) + if (finalAnswerTagMatch) { + return finalAnswerTagMatch[1].trim() + } + + const finalAnswerMatch = response.match(FINAL_ANSWER_RE) + if (finalAnswerMatch) { + return finalAnswerMatch[1].trim() + } + + const chineseFinalMatch = response.match(CHINESE_FINAL_ANSWER_RE) + if (chineseFinalMatch) { + return chineseFinalMatch[1].trim() + } + + const trimmedResponse = response.trim() + if (/^[#>]/.test(trimmedResponse)) { + return trimmedResponse + } + + if (response.length > 300 && (response.includes('**') || response.includes('>'))) { + const thoughtMatch = response.match(/^Thought:[\s\S]*?(?=\n\n[^T]|\n\n$)/i) + if (thoughtMatch) { + const afterThought = response.substring(thoughtMatch[0].length).trim() + if (afterThought.length > 100) { + return afterThought + } + } + } + + return null +} diff --git a/frontend/src/components/reportReferences.js b/frontend/src/components/reportReferences.js new file mode 100644 index 00000000..b2baac9f --- /dev/null +++ b/frontend/src/components/reportReferences.js @@ -0,0 +1,7 @@ +export const resolveReportReferenceValue = (reportId, unavailableLabel) => { + if (typeof reportId === 'string' && reportId.trim()) { + return reportId + } + + return unavailableLabel +} diff --git a/frontend/src/components/simulationLogMessages.js b/frontend/src/components/simulationLogMessages.js new file mode 100644 index 00000000..2e72d7db --- /dev/null +++ b/frontend/src/components/simulationLogMessages.js @@ -0,0 +1,54 @@ +export function getPrepareStageLabel(stage, fallbackLabel, t) { + if (!stage) { + return fallbackLabel || '' + } + + const keyMap = { + generating_profiles: 'step2.logs.stageLabels.generatingProfiles', + generating_config: 'step2.logs.stageLabels.generatingConfig', + copying_scripts: 'step2.logs.stageLabels.copyingScripts', + } + + const key = keyMap[stage] + return key ? t(key) : (fallbackLabel || stage) +} + +export function formatPrepareProgressLog(detail, t) { + if (!detail?.item_description) { + return '' + } + + const stageLabel = getPrepareStageLabel(detail.current_stage, detail.current_stage_name, t) + const params = { + current: detail.current_item, + total: detail.total_items, + stage: stageLabel, + item: detail.item_description, + index: detail.stage_index, + stages: detail.total_stages, + } + + if (detail.total_items > 0) { + return t('step2.logs.progressStageWithItems', params) + } + + return t('step2.logs.progressStageWithoutItems', params) +} + +export function formatSimulationPidLog(pid, t) { + return t('step3.pidLog', { pid: pid || '-' }) +} + +export function formatSimulationRoundLog( + { platform, currentRound, totalRounds, simulatedHours, actionsCount }, + t +) { + const platformLabel = t(`step3.platformNames.${platform}`) + return t('step3.roundProgressLog', { + platform: platformLabel, + currentRound, + totalRounds, + simulatedHours: simulatedHours || 0, + actionsCount: actionsCount || 0, + }) +} diff --git a/frontend/src/components/simulationReplay.js b/frontend/src/components/simulationReplay.js new file mode 100644 index 00000000..0e6a8d80 --- /dev/null +++ b/frontend/src/components/simulationReplay.js @@ -0,0 +1,40 @@ +export const isReplayOnlyRoute = (value) => value === '1' || value === 'true' + +export const shouldAutoStartSimulation = ({ replayOnly = false, resumed = false }) => + !replayOnly && !resumed + +export const getReplayNoticeKey = ({ replayOnly = false, resumed = false, runnerStatus = '' }) => { + if (!replayOnly) { + return null + } + + if (!resumed) { + return 'step3.replayOnlyNoRunNotice' + } + + if (runnerStatus === 'failed') { + return 'step3.replayOnlyFailedNotice' + } + + if (runnerStatus === 'stopped') { + return 'step3.replayOnlyStoppedNotice' + } + + return null +} + +export const getRestartButtonLabelKey = ({ replayOnly = false, resumed = false, runnerStatus = '' }) => { + if (!replayOnly) { + return 'step3.restartSimulation' + } + + if (!resumed) { + return 'step3.startPreparedSimulation' + } + + if (runnerStatus === 'failed' || runnerStatus === 'stopped') { + return 'step3.restartPreparedSimulation' + } + + return 'step3.restartSimulation' +} diff --git a/frontend/src/components/simulationTimeline.js b/frontend/src/components/simulationTimeline.js new file mode 100644 index 00000000..15fec4c8 --- /dev/null +++ b/frontend/src/components/simulationTimeline.js @@ -0,0 +1,54 @@ +const ACTION_TYPE_KEY_MAP = { + CREATE_POST: 'post', + REPOST: 'repost', + LIKE_POST: 'like', + CREATE_COMMENT: 'comment', + LIKE_COMMENT: 'like', + DO_NOTHING: 'idle', + FOLLOW: 'follow', + SEARCH_POSTS: 'search', + QUOTE_POST: 'quote', + UPVOTE_POST: 'upvote', + DOWNVOTE_POST: 'downvote', +} + +const PLATFORM_ACTION_KEYS = { + twitter: ['post', 'like', 'repost', 'quote', 'follow', 'idle'], + reddit: ['post', 'comment', 'like', 'dislike', 'search', 'trend', 'follow', 'mute', 'refresh', 'idle'], +} + +export const getTimelinePlatformName = (platform, t) => + t(`step3.platformNames.${platform}`) + +export const getTimelineAvailableActions = (platform, t) => + (PLATFORM_ACTION_KEYS[platform] || []).map((key) => t(`step3.availableActionList.${key}`)) + +export const getTimelineActionTypeLabel = (type, t) => { + const key = ACTION_TYPE_KEY_MAP[type] + return key ? t(`step3.actionTypes.${key}`) : type || t('step3.actionTypes.unknown') +} + +export const describeTimelineAction = (action, t) => { + const args = action?.action_args || {} + + switch (action?.action_type) { + case 'REPOST': + return t('step3.repostedFrom', { user: args.original_author_name || t('step3.unknownUser') }) + case 'LIKE_POST': + return t('step3.likedPost', { user: args.post_author_name || t('step3.unknownUser') }) + case 'CREATE_COMMENT': + return t('step3.replyToPost', { id: args.post_id || '-' }) + case 'SEARCH_POSTS': + return t('step3.searchQuery') + case 'FOLLOW': + return t('step3.followedUser', { user: args.target_user || args.user_id || t('step3.unknownUser') }) + case 'UPVOTE_POST': + return t('step3.upvotedPost') + case 'DOWNVOTE_POST': + return t('step3.downvotedPost') + case 'DO_NOTHING': + return t('step3.actionSkipped') + default: + return '' + } +} diff --git a/frontend/src/components/step2Recovery.js b/frontend/src/components/step2Recovery.js new file mode 100644 index 00000000..a4493518 --- /dev/null +++ b/frontend/src/components/step2Recovery.js @@ -0,0 +1,48 @@ +import { buildSimulationReplayRoute, hasReplayableSimulationState } from './historyPlayback.js' + +export const getStep2RecoveryState = (simulation = {}) => { + if (!hasReplayableSimulationState(simulation)) { + return null + } + + const runnerStatus = simulation.runner_status || 'idle' + const route = buildSimulationReplayRoute(simulation.simulation_id) + + if (runnerStatus === 'running' || runnerStatus === 'starting') { + return { + noticeKey: 'step2.savedRunResumeNotice', + actionKey: 'step2.openSavedRun', + route, + } + } + + if (runnerStatus === 'failed') { + return { + noticeKey: 'step2.savedRunFailedNotice', + actionKey: 'step2.restartPreparedRun', + route, + } + } + + if (runnerStatus === 'stopped') { + return { + noticeKey: 'step2.savedRunStoppedNotice', + actionKey: 'step2.restartPreparedRun', + route, + } + } + + if (runnerStatus === 'completed') { + return { + noticeKey: 'step2.savedRunCompletedNotice', + actionKey: 'step2.openSavedRun', + route, + } + } + + return { + noticeKey: 'step2.savedRunReplayNotice', + actionKey: 'step2.openSavedRun', + route, + } +} diff --git a/frontend/src/components/step5Profiles.js b/frontend/src/components/step5Profiles.js new file mode 100644 index 00000000..82d921bf --- /dev/null +++ b/frontend/src/components/step5Profiles.js @@ -0,0 +1,235 @@ +import { deriveInterviewTimeoutSeconds, resolveTimeoutMs } from '../api/timeout.js' + +const PLATFORM_ORDER = { + reddit: 0, + twitter: 1, +} + +const PLATFORM_LABELS = { + reddit: 'Reddit', + twitter: 'Twitter', +} + +export const getPlatformLabel = (platform, t) => + t ? t(`step5.platforms.${platform}`) : (PLATFORM_LABELS[platform] || platform) + +const normalizeAgentId = (value, fallbackIndex) => { + const parsed = Number.parseInt(value, 10) + return Number.isInteger(parsed) ? parsed : fallbackIndex +} + +export const normalizePlatformProfiles = (platform, profiles = []) => { + if (!Array.isArray(profiles)) { + return [] + } + + return profiles.map((profile, index) => { + const agentId = normalizeAgentId(profile?.agent_id, index) + + return { + ...profile, + agent_id: agentId, + platform, + platformLabel: PLATFORM_LABELS[platform] || platform, + profileKey: `${platform}_${agentId}`, + } + }) +} + +export const mergeInteractionProfiles = (entries = []) => { + return entries + .flatMap(({ platform, profiles }) => normalizePlatformProfiles(platform, profiles)) + .sort((left, right) => { + const platformDelta = + (PLATFORM_ORDER[left.platform] ?? Number.MAX_SAFE_INTEGER) - + (PLATFORM_ORDER[right.platform] ?? Number.MAX_SAFE_INTEGER) + + if (platformDelta !== 0) { + return platformDelta + } + + return left.agent_id - right.agent_id + }) +} + +export const getEnabledProfilePlatforms = (simulationState) => { + if (!simulationState || typeof simulationState !== 'object') { + return ['reddit', 'twitter'] + } + + const enabledPlatforms = [] + if (simulationState.enable_reddit) { + enabledPlatforms.push('reddit') + } + if (simulationState.enable_twitter) { + enabledPlatforms.push('twitter') + } + + return enabledPlatforms.length > 0 ? enabledPlatforms : ['reddit', 'twitter'] +} + +export const buildInterviewRequest = (profile, prompt) => ({ + agent_id: profile.agent_id, + prompt, + platform: profile.platform, +}) + +export const extractInterviewResponseContent = (payload, profile) => { + const resultData = payload?.result || payload || {} + const results = resultData.results || resultData + const preferredKeys = [ + `${profile.platform}_${profile.agent_id}`, + `reddit_${profile.agent_id}`, + `twitter_${profile.agent_id}`, + ] + + if (results && typeof results === 'object' && !Array.isArray(results)) { + for (const key of preferredKeys) { + const match = results[key] + if (match?.response || match?.answer) { + return match.response || match.answer + } + } + + for (const value of Object.values(results)) { + if (value?.response || value?.answer) { + return value.response || value.answer + } + } + } + + if (Array.isArray(results)) { + const matched = results.find((item) => { + const itemAgentId = normalizeAgentId(item?.agent_id, -1) + return itemAgentId === profile.agent_id && (!item?.platform || item.platform === profile.platform) + }) || results[0] + + if (matched?.response || matched?.answer) { + return matched.response || matched.answer + } + } + + return null +} + +export const formatAgentRole = (profile, fallbackRole, t) => { + const role = profile?.profession || fallbackRole + const platformLabel = profile?.platform ? getPlatformLabel(profile.platform, t) : profile?.platformLabel + + return platformLabel ? `${platformLabel} · ${role}` : role +} + +const isTimeoutMessage = (message) => /timeout|timed out/i.test(message) +const INTERVIEW_TIMEOUT_PATTERNS = [ + /^等待(?:批量|全局)?Interview响应超时/, + /^Waiting for (?:batch |all-agent |global )?Interview response timed out/i, + /^Timed out while waiting for the (?:batch |global )?interview response/i, +] +const ENV_CLOSED_PATTERNS = [ + /模拟环境未运行或已关闭/, + /The simulation environment is not running or has already closed/i, + /The environment is not running or has already closed/i, + /The environment is already closed/i, +] + +const isInterviewTimeoutMessage = (message) => + INTERVIEW_TIMEOUT_PATTERNS.some((pattern) => pattern.test(message)) || isTimeoutMessage(message) + +const isClosedEnvironmentMessage = (message) => + ENV_CLOSED_PATTERNS.some((pattern) => pattern.test(message)) + +export const summarizeInterviewEnvStatus = (envStatus, t) => { + if (!envStatus) { + return '' + } + + const availablePlatforms = [] + if (envStatus.reddit_available) { + availablePlatforms.push(getPlatformLabel('reddit', t)) + } + if (envStatus.twitter_available) { + availablePlatforms.push(getPlatformLabel('twitter', t)) + } + + if (!envStatus.env_alive) { + return t('step5.interviewEnvClosedBanner') + } + + if (availablePlatforms.length === 0) { + return t('step5.interviewEnvNoPlatformBanner') + } + + return t('step5.interviewEnvReadyBanner', { platforms: availablePlatforms.join(' / ') }) +} + +export const getInterviewGuardMessage = (envStatus, profiles, t) => { + if (!envStatus?.env_alive) { + return t('step5.interviewEnvClosedError') + } + + const unavailablePlatforms = new Set() + for (const profile of profiles || []) { + const platform = profile?.platform + if (!platform) { + continue + } + + if (!envStatus[`${platform}_available`]) { + unavailablePlatforms.add(getPlatformLabel(platform, t)) + } + } + + if (unavailablePlatforms.size > 0) { + return t('step5.interviewPlatformUnavailable', { + platforms: Array.from(unavailablePlatforms).join(' / '), + }) + } + + return '' +} + +export const formatInterviewFailureMessage = (message, t) => { + const normalized = typeof message === 'string' ? message.trim() : '' + if (!normalized) { + return t('step5.requestFailed') + } + + if (isClosedEnvironmentMessage(normalized)) { + return t('step5.interviewEnvClosedError') + } + + if (isInterviewTimeoutMessage(normalized)) { + return t('step5.interviewTimeoutError', { message: normalized }) + } + + return normalized +} + +export const summarizeInterviewTimeoutBudget = ({ + requestTimeoutMs, + selectedCount = 0, + t, +}) => { + const requestSeconds = Math.floor(resolveTimeoutMs(requestTimeoutMs) / 1000) + const singleSeconds = deriveInterviewTimeoutSeconds({ + requestTimeoutMs, + interviewsCount: 1, + }) + + if (!Number.isFinite(selectedCount) || selectedCount <= 0) { + return t('step5.interviewTimeoutHintNoSelection', { + singleSeconds, + requestSeconds, + }) + } + + return t('step5.interviewTimeoutHintWithSelection', { + singleSeconds, + selectedCount: Math.floor(selectedCount), + batchSeconds: deriveInterviewTimeoutSeconds({ + requestTimeoutMs, + interviewsCount: selectedCount, + }), + requestSeconds, + }) +} diff --git a/frontend/src/components/step5Recovery.js b/frontend/src/components/step5Recovery.js new file mode 100644 index 00000000..987b321c --- /dev/null +++ b/frontend/src/components/step5Recovery.js @@ -0,0 +1,14 @@ +import { getStep2RecoveryState } from './step2Recovery.js' + +export const getStep5RecoveryState = ({ simulation = null, envStatus = null } = {}) => { + const recoveryState = getStep2RecoveryState(simulation || {}) + if (!recoveryState) { + return null + } + + if (!envStatus || envStatus.env_alive) { + return null + } + + return recoveryState +} diff --git a/frontend/src/components/verificationBundle.js b/frontend/src/components/verificationBundle.js new file mode 100644 index 00000000..3a20c2cd --- /dev/null +++ b/frontend/src/components/verificationBundle.js @@ -0,0 +1,38 @@ +const cleanValue = (value) => { + if (typeof value !== 'string') { + return '' + } + + return value.trim() +} + +const addLine = (lines, key, value) => { + const cleaned = cleanValue(value) + if (cleaned) { + lines.push(`${key}: ${cleaned}`) + } +} + +export const buildVerificationReferenceBundle = ({ + simulationId = '', + reportId = '', + timestamp = '', +} = {}) => { + const cleanedSimulationId = cleanValue(simulationId) + const cleanedReportId = cleanValue(reportId) + if (!cleanedSimulationId && !cleanedReportId) { + return '' + } + + const lines = ['MiroFish verification reference'] + + addLine(lines, 'simulation_id', cleanedSimulationId) + addLine(lines, 'report_id', cleanedReportId) + addLine(lines, 'timestamp', timestamp) + + if (cleanedReportId) { + lines.push(`report_markdown_path: backend/uploads/reports/${cleanedReportId}/full_report.md`) + } + + return lines.join('\n') +} diff --git a/frontend/src/i18n/index.js b/frontend/src/i18n/index.js new file mode 100644 index 00000000..0ac36c39 --- /dev/null +++ b/frontend/src/i18n/index.js @@ -0,0 +1,63 @@ +import { createI18n } from 'vue-i18n' +import en from './locales/en.js' +import zh from './locales/zh.js' + +const LOCALE_KEY = 'mirofish-locale' +const DEFAULT_LOCALE = 'zh' + +export const normalizeLocale = (locale) => { + if (typeof locale !== 'string') { + return null + } + + const normalized = locale.trim().toLowerCase() + if (normalized.startsWith('zh')) { + return 'zh' + } + if (normalized.startsWith('en')) { + return 'en' + } + return null +} + +export const resolveBrowserLocale = (browserLocale) => normalizeLocale(browserLocale) || DEFAULT_LOCALE + +export const getStoredLocale = () => { + if (typeof window === 'undefined') { + return DEFAULT_LOCALE + } + + try { + const locale = window.localStorage.getItem(LOCALE_KEY) + const storedLocale = normalizeLocale(locale) + if (storedLocale) { + return storedLocale + } + + return resolveBrowserLocale(window.navigator?.language) + } catch { + return resolveBrowserLocale(window.navigator?.language) + } +} + +export const setStoredLocale = (locale) => { + if (typeof window === 'undefined') { + return + } + + try { + window.localStorage.setItem(LOCALE_KEY, locale) + } catch { + // Ignore storage failures and keep the in-memory locale. + } +} + +export default createI18n({ + legacy: false, + locale: getStoredLocale(), + fallbackLocale: DEFAULT_LOCALE, + messages: { + en, + zh, + }, +}) diff --git a/frontend/src/i18n/locales/en.js b/frontend/src/i18n/locales/en.js new file mode 100644 index 00000000..ea0d7b3e --- /dev/null +++ b/frontend/src/i18n/locales/en.js @@ -0,0 +1,929 @@ +export default { + common: { + loading: 'Loading...', + none: 'None', + error: 'Error', + completed: 'Completed', + processing: 'Processing', + }, + nav: { + visitGithub: 'Visit our GitHub', + zh: '中文', + en: 'English', + }, + apiConfig: { + trigger: 'Backend API', + title: 'Frontend Backend Target', + description: 'Override the backend API URL at runtime without rebuilding the frontend. Leave it blank to use the automatic or env-based target.', + inputLabel: 'Backend API base URL', + placeholder: 'https://example.com:5001', + save: 'Apply', + reset: 'Use Default', + saved: 'Custom backend URL saved. New requests will use it immediately.', + autoSaved: 'Cleared the custom backend URL. Automatic detection is active again.', + resetDone: 'Reverted to the default backend target.', + invalid: 'Enter a full http:// or https:// backend URL.', + current: 'Current target:', + autoMode: 'Automatic', + customMode: 'Custom', + diagnostics: { + title: 'Backend LLM Diagnostics', + description: 'Reads the backend config-status endpoint so you can verify direct Codex/OpenAI-compatible wiring without inspecting raw API responses.', + loading: 'Checking backend configuration...', + refresh: 'Refresh', + configured: 'Backend config detected', + configuredOpenAI: 'Direct OPENAI/Codex-compatible path detected', + baseUrlConflictTitle: 'Conflicting backend base URLs detected', + baseUrlConflictNote: '{configuredEnvNames} are set to different values. MiroFish is currently using {selectedEnv}={selectedValue}.', + zepMissingNote: 'The direct LLM path is configured, but Step 1 graph build and graph-backed report tools still require ZEP_API_KEY until a non-Zep backend is landed.', + nextStepsTitle: 'Next usable path', + nextStepOpenStep2: 'Open Step 2 to generate the simulation environment, then continue into Step 3 with the direct backend.', + nextStepReuseStep5: 'After Step 2/3 has produced a simulation environment, Step 5 can still be used for role interaction even without a Step 4 report.', + nextStepWaitForNonZep: 'Step 1 graph build and Step 4 graph-backed report tools remain blocked until ZEP_API_KEY is configured or a non-Zep graph backend is added.', + incomplete: 'Backend config needs attention', + modeLabel: 'Backend mode', + sourceLabel: 'Resolved config source', + envLabel: 'Resolved env vars', + baseUrlLabel: 'Backend LLM base URL', + modelLabel: 'Backend model', + directLlmLabel: 'Direct LLM usage', + graphBuildLabel: 'Step 1 graph build', + reportToolsLabel: 'Step 4 graph-backed report tools', + step5Label: 'Step 5 on existing simulation', + modeOpenAICompatible: 'OpenAI-compatible', + sourceOpenAIAliases: 'Direct OPENAI_* aliases', + sourceMixedAliases: 'Mixed OPENAI_* and LLM_* aliases', + sourceProjectAliases: 'Project LLM_* aliases', + sourceUnknown: 'Not resolved', + capabilityReady: 'Ready', + capabilityNeedsZep: 'Needs ZEP_API_KEY', + capabilityReadyExistingSimulation: 'Ready when an existing simulation environment is available', + capabilityNeedsExistingSimulation: 'Needs an existing simulation environment', + capabilityNeedsBackendConfig: 'Needs backend config', + }, + }, + home: { + tagline: 'Simple & Universal Swarm Intelligence Engine', + version: '/ v0.1-preview', + title1: 'Upload Any Report', + title2: 'Predict the Future Instantly', + desc1: 'With just a paragraph of text, ', + desc2: 'MiroFish', + desc3: ' can automatically generate a parallel world powered by up to ', + desc4: 'millions of Agents', + desc5: ' from the seeds of reality within. Inject variables from a god\'s-eye view and find ', + desc6: '"local optima"', + desc7: ' in complex group interactions under dynamic environments.', + slogan: 'Let the future be rehearsed in Agent swarms, let decisions win after a hundred battles.', + systemStatus: 'System Status', + ready: 'Ready', + readyDesc: 'Prediction engine on standby. Upload unstructured data to initialize a simulation sequence.', + lowCost: 'Low Cost', + lowCostDesc: 'Typical simulations average about $5 per run', + highAvailable: 'High Scale', + highAvailableDesc: 'Supports up to millions of Agents', + workflow: 'Workflow Sequence', + step1Title: 'Graph Build', + step1Desc: 'Reality seed extraction, memory injection, and GraphRAG construction', + step2Title: 'Environment Setup', + step2Desc: 'Entity extraction, persona generation, and simulation parameter injection', + step3Title: 'Simulation', + step3Desc: 'Dual-platform simulation with dynamic memory updates', + step4Title: 'Report Generation', + step4Desc: 'ReportAgent uses tool-assisted reasoning against the post-simulation world', + step5Title: 'Deep Interaction', + step5Desc: 'Talk with any simulated agent or with ReportAgent', + seedLabel: '01 / Reality Seed', + formatHint: 'Supported: PDF, MD, TXT', + dragUpload: 'Drag files to upload', + clickBrowse: 'or click to browse', + firstRunTip: 'For a first run, start with source material under about 10k words and keep the simulation near 30 rounds so you can verify graph build and environment startup before scaling up.', + inputParams: 'Input Parameters', + promptLabel: '>_ 02 / Simulation Prompt', + promptPlaceholder: '// Describe the simulation or prediction you want in natural language', + engineBadge: 'Engine: MiroFish-V1.0', + startEngine: 'Start Engine', + initializing: 'Initializing...', + }, + mainView: { + graph: 'Graph', + split: 'Split', + workbench: 'Workbench', + stepGraph: 'Graph Build', + stepEnv: 'Environment Setup', + stepSim: 'Simulation', + stepReport: 'Report Generation', + stepInteraction: 'Deep Interaction', + statusReady: 'Ready', + statusBuilding: 'Building Graph', + statusOntology: 'Generating Ontology', + statusInit: 'Initializing', + projectFailed: 'Project failed', + logs: { + enterStep: 'Entering Step {step}: {name}', + returnStep: 'Returning to Step {step}: {name}', + customRounds: 'Custom simulation rounds: {count}', + init: 'Project view initialized', + noPendingFiles: 'No pending files found for the new project flow', + uploadingAndAnalyzingDocs: 'Uploading and analyzing docs...', + startOntologyGeneration: 'Starting ontology generation: uploading files...', + ontologyGenerated: 'Ontology generated successfully for project {id}', + ontologyGenerationFailed: 'Ontology generation failed', + ontologyGenerationError: 'Error generating ontology: {message}', + newProjectException: 'Exception in new-project flow: {message}', + loadingProject: 'Loading project {id}...', + projectLoaded: 'Project loaded. Status: {status}', + loadProjectError: 'Error loading project: {message}', + loadProjectException: 'Exception while loading project: {message}', + startingBuild: 'Initiating graph build...', + graphBuildTaskStarted: 'Graph build task started. Task ID: {id}', + startBuildError: 'Error starting build: {message}', + startBuildException: 'Exception while starting graph build: {message}', + graphPollingStarted: 'Started polling for graph data...', + graphDataRefreshed: 'Graph data refreshed. Nodes: {nodeCount}, Edges: {edgeCount}', + graphBuildCompleted: 'Graph build task completed.', + graphBuildFailed: 'Graph build task failed: {message}', + loadingGraph: 'Loading full graph data: {id}', + graphLoaded: 'Graph data loaded successfully.', + graphLoadFailed: 'Failed to load graph data: {message}', + graphLoadException: 'Exception while loading graph: {message}', + graphRefreshTriggered: 'Manual graph refresh triggered.', + graphPollingStopped: 'Graph polling stopped.', + }, + }, + history: { + title: 'History', + references: 'Saved References', + graphBuild: 'Graph Build', + envSetup: 'Environment Setup', + report: 'Report', + moreFiles: ' files', + noFiles: 'No files', + simRequirement: 'Simulation Requirement', + relatedFiles: 'Related Files', + noRelatedFiles: 'No related files', + unknownFile: 'Unknown file', + playback: 'Playback', + graphBuildBtn: 'Graph Build', + envSetupBtn: 'Environment Setup', + simulationRunBtn: 'Simulation Timeline', + reportBtn: 'Report', + interactionBtn: 'Deep Interaction', + deleteRecord: 'Delete record', + deleting: 'Deleting...', + copyId: 'Copy ID', + copyBundle: 'Copy Bundle', + copied: 'Copied', + exportMd: 'Export MD', + simulationIdLabel: 'Simulation ID', + reportIdLabel: 'Report ID', + deleteConfirm: 'Delete {simulationId} and its local history files? This cannot be undone.', + deleteFailed: 'Failed to delete the history record.', + playbackHint: 'Refreshing or closing the browser does not stop backend jobs by itself. You can reopen Step 1, Step 2, Step 4, and the Step 5 workspace from history. Live Step 3 and Step 5 interaction still require a prepared runtime session, so reopening from history may show recovery guidance instead of an active interview.', + unnamedSimulation: 'Untitled simulation', + unknownSimulationId: 'SIM_UNKNOWN', + notStarted: 'Not started', + roundProgress: '{current}/{total} rounds', + }, + step1Graph: { + ontologyTitle: 'Ontology Generation', + completed: 'Completed', + generating: 'Generating', + pending: 'Pending', + ontologyDescription: 'The LLM analyzes uploaded documents and the simulation request, extracts seed signals, and generates an ontology suited to the scenario.', + analyzingDocuments: 'Analyzing documents...', + detailEntityType: 'ENTITY', + detailRelationType: 'RELATION', + detailAttributes: 'ATTRIBUTES', + detailExamples: 'EXAMPLES', + detailConnections: 'CONNECTIONS', + generatedEntityTypes: 'GENERATED ENTITY TYPES', + generatedRelationTypes: 'GENERATED RELATION TYPES', + graphBuildTitle: 'GraphRAG Build', + graphBuildDescription: 'Using the generated ontology, the backend chunks the documents and calls Zep to build the knowledge graph, extract entities/relationships, and write temporal memory plus community summaries.', + entityNodes: 'Entity Nodes', + relationEdges: 'Relationship Edges', + schemaTypes: 'Schema Types', + buildCompleteTitle: 'Build Complete', + inProgress: 'In progress', + buildCompleteDescription: 'The graph build is complete. Continue to the next step to set up the simulation environment.', + creatingSimulation: 'Creating...', + enterEnvSetup: 'Go to Environment Setup ->', + systemDashboard: 'System Dashboard', + noProject: 'NO_PROJECT', + missingProjectOrGraph: 'Missing project or graph information', + createSimulationFailed: 'Failed to create simulation', + createSimulationFailedWithMessage: 'Failed to create simulation: {message}', + createSimulationException: 'Simulation creation exception', + createSimulationExceptionWithMessage: 'Simulation creation exception: {message}', + unknownError: 'Unknown error', + }, + step2: { + instanceInitTitle: 'Initialize Simulation Instance', + completed: 'Completed', + initializing: 'Initializing', + waiting: 'Waiting', + generating: 'Generating', + orchestrating: 'Orchestrating', + inProgress: 'In progress', + instanceInitDescription: 'Create the simulation instance and load the initial world-configuration template.', + projectIdLabel: 'Project ID', + graphIdLabel: 'Graph ID', + simulationIdLabel: 'Simulation ID', + taskIdLabel: 'Task ID', + asyncTaskCompleted: 'Async task completed', + generateProfilesTitle: 'Generate Agent Profiles', + generateProfilesDescription: 'Use the knowledge graph and seed context to initialize simulated individuals, then assign behavior and memory traces grounded in the source material.', + currentAgentCount: 'Current Agents', + expectedAgentCount: 'Expected Agents', + relatedTopicsCount: 'Linked Topics', + generatedProfilesTitle: 'Generated Agent Profiles', + unknownProfileName: 'Unknown', + unknownProfession: 'Unknown profession', + noBio: 'No bio available', + generateConfigTitle: 'Generate Dual-Platform Simulation Config', + generateConfigDescription: 'The LLM configures world time flow, recommendation logic, activity windows, posting cadence, and event triggers from the simulation brief and seed data.', + simulationDuration: 'Simulation Duration', + minutesPerRound: 'Minutes per Round', + totalRounds: 'Total Rounds', + agentsPerHour: 'Agents per Hour', + peakHours: 'Peak Hours', + workHours: 'Work Hours', + morningHours: 'Morning Hours', + offPeakHours: 'Off-Peak Hours', + agentConfigTitle: 'Agent Configuration', + activeHours: 'Active Hours', + postsPerHour: 'Posts / hr', + commentsPerHour: 'Comments / hr', + responseDelay: 'Response Delay', + activityLevel: 'Activity Level', + sentimentBias: 'Sentiment Bias', + influenceWeight: 'Influence', + recommendationConfigTitle: 'Recommendation Settings', + platform1Title: 'Platform 1: Feed / Square', + platform2Title: 'Platform 2: Topic / Community', + recencyWeight: 'Recency Weight', + popularityWeight: 'Popularity Weight', + relevanceWeight: 'Relevance Weight', + viralThreshold: 'Viral Threshold', + echoChamberStrength: 'Echo Chamber Strength', + llmReasoningTitle: 'LLM Configuration Reasoning', + initialActivationTitle: 'Initial Activation Orchestration', + initialActivationDescription: 'Generate initial activation events and hot topics from the narrative direction so the simulated world starts from a guided state.', + narrativeDirection: 'Narrative Direction', + initialHotTopics: 'Initial Hot Topics', + initialActivationSequence: 'Initial Activation Sequence ({count})', + readyTitle: 'Ready to Run', + readyDescription: 'The simulation environment is ready. You can start the run now.', + roundConfigTitle: 'Simulation Round Settings', + roundConfigDescription: 'MiroFish plans {hours} hours of simulated time automatically, with each round representing {minutes} minutes of real-world elapsed time.', + savedRunLabel: 'Saved Step 3 State', + savedRunResumeNotice: 'A Step 3 run is already attached to this simulation. Open the saved run to reattach to the live timeline instead of rebuilding Step 2.', + savedRunFailedNotice: 'This simulation keeps the last failed Step 3 state. After fixing the quota or API-key problem, open it here and restart the same prepared run without regenerating Step 2.', + savedRunStoppedNotice: 'This simulation keeps a stopped Step 3 state. Open it here to inspect the saved timeline or restart the same prepared run without regenerating Step 2.', + savedRunCompletedNotice: 'This simulation already has a completed Step 3 timeline. Open it here to review the saved run or launch another run from the same prepared environment.', + savedRunReplayNotice: 'This simulation already has saved Step 3 progress. Open it here to reuse the existing prepared environment instead of rebuilding Step 2.', + openSavedRun: 'Open Saved Step 3 Run', + restartPreparedRun: 'Restart Prepared Step 3 Run', + customMode: 'Custom', + roundUnit: 'rounds', + estimatedRuntime: 'At 100 agents: estimated runtime about {minutes} min', + recommendedRounds: '40 (Recommended)', + firstRunTip: 'For a first run, switch to custom mode and reduce the round count to preview results faster and lower failure risk ->', + backToGraph: '<- Back to Graph Build', + startSimulation: 'Start Dual-World Parallel Simulation ->', + visibleAge: 'Visible Age', + visibleGender: 'Visible Gender', + countryRegion: 'Country / Region', + visibleMbti: 'Visible MBTI', + profileBio: 'Profile Bio', + relatedTopics: 'Topics Linked to the Seed', + detailedPersona: 'Detailed Persona Background', + systemDashboard: 'SYSTEM DASHBOARD', + noSimulation: 'NO_SIMULATION', + unknownError: 'Unknown error', + hoursValue: '{value} hr', + minutesValue: '{value} min', + roundsValue: '{value} rounds', + countValue: '{value}', + agentId: 'Agent {id}', + delayRangeMinutes: '{min}-{max} min', + ageValue: '{value} years', + gender: { + male: 'Male', + female: 'Female', + other: 'Other', + }, + personaDimensions: { + eventJourney: { + title: 'Event Journey', + desc: 'The person\'s full trajectory through the event.', + }, + behaviorPattern: { + title: 'Behavior Pattern', + desc: 'Observed tendencies and decision style.', + }, + memoryImprint: { + title: 'Memory Imprint', + desc: 'Memories formed from the seed material.', + }, + socialGraph: { + title: 'Social Network', + desc: 'Individual links and interaction graph.', + }, + }, + logs: { + missingSimulationId: 'Error: missing simulationId', + instanceCreated: 'Simulation instance created: {id}', + preparingEnvironment: 'Preparing simulation environment...', + reusePreparedData: 'Found completed preparation data and reusing it directly', + prepareTaskStarted: 'Preparation task started', + taskId: ' └─ Task ID: {id}', + entityCount: 'Loaded {count} entities from the Zep graph', + entityTypes: ' └─ Entity types: {types}', + startPolling: 'Started polling preparation progress...', + prepareFailed: 'Preparation failed: {message}', + prepareException: 'Preparation exception: {message}', + prepareCompleted: '✓ Preparation completed', + prepareFailedMarked: '✗ Preparation failed: {message}', + generatingProfiles: 'Generating agent profiles...', + profileProgress: '→ Agent profile {current}/{total}: {name} ({profession})', + allProfilesCompleted: '✓ All {count} agent profiles generated', + generatingProfileConfig: 'Generating agent profile configuration...', + generatingSimulationConfig: 'Calling the LLM to generate simulation parameters...', + configGenerated: '✓ Simulation configuration generated', + summaryAgents: ' ├─ Agents: {count}', + summaryHours: ' ├─ Simulation duration: {hours} hr', + summaryPosts: ' ├─ Initial posts: {count}', + summaryPostsOnly: ' └─ Initial posts: {count}', + summaryTopics: ' ├─ Hot topics: {count}', + summaryPlatforms: ' └─ Platform config: Twitter {twitter}, Reddit {reddit}', + timeConfig: 'Time config: {minutes} min per round, {rounds} rounds total', + narrativeDirection: 'Narrative direction: {narrative}', + environmentReady: '✓ Environment setup complete, ready to simulate', + startSimulationCustomRounds: 'Starting simulation with a custom round limit: {count}', + startSimulationAutoRounds: 'Starting simulation with the planned round count: {count}', + progressStageWithItems: '[{index}/{stages}] {stage}: {current}/{total} - {item}', + progressStageWithoutItems: '[{index}/{stages}] {stage}: {item}', + loadingPreparedData: 'Loading previously prepared configuration...', + loadedProfiles: 'Loaded {count} agent profiles', + configLoaded: '✓ Simulation configuration loaded', + configPolling: 'Configuration still generating, starting polling...', + loadConfigFailed: 'Failed to load configuration: {message}', + init: 'Step 2 environment setup initialized', + stageLabels: { + generatingProfiles: 'Generating agent profiles', + generatingConfig: 'Generating simulation config', + copyingScripts: 'Preparing simulation scripts', + }, + }, + }, + process: { + navStep: 'Graph Build', + graphTitle: 'Live Knowledge Graph', + nodes: 'nodes', + relations: 'relations', + refreshGraph: 'Refresh graph', + exitFullscreen: 'Exit fullscreen', + enterFullscreen: 'Fullscreen', + liveUpdating: 'Live updating...', + nodeDetails: 'Node Details', + relationship: 'Relationship', + name: 'Name', + aliases: 'Merged Aliases', + uuid: 'UUID', + created: 'Created', + properties: 'Properties', + summary: 'Summary', + labels: 'Labels', + label: 'Label', + type: 'Type', + fact: 'Fact', + episodes: 'Episodes', + validFrom: 'Valid From', + invalidAt: 'Invalid At', + expiredAt: 'Expired At', + graphLoading: 'Loading graph data...', + waitingOntology: 'Waiting for ontology generation', + waitingOntologyHint: 'Graph building will start automatically when ontology generation is complete', + graphBuilding: 'Building graph', + graphBuildingHint: 'Data will appear shortly...', + processTitle: 'Build Pipeline', + apiDescription: 'API Description', + progress: 'Progress', + phase1Title: 'Ontology Generation', + phase1Desc: 'After document upload, the LLM analyzes the content and generates an ontology for simulation use cases (entity types + relationship types).', + generatedEntities: 'Generated entity types ({count})', + generatedRelations: 'Generated relationship types ({count})', + moreRelations: '+{count} more relationships...', + waitingOntologyShort: 'Waiting for ontology generation...', + phase2Title: 'Graph Build', + phase2Desc: 'Using the generated ontology, the backend chunks the documents and calls the Zep API to build a knowledge graph with extracted entities and relationships.', + waitingOntologyComplete: 'Waiting for ontology generation to finish...', + buildResult: 'Build Result', + entityNodes: 'Entity Nodes', + relationEdges: 'Relationship Edges', + entityTypes: 'Entity Types', + phase3Title: 'Build Complete', + phase3Desc: 'Ready for the next step', + nextStep: 'Go to Environment Setup', + projectInfo: 'Project Info', + projectName: 'Project Name', + projectId: 'Project ID', + graphId: 'Graph ID', + simulationRequirement: 'Simulation Requirement', + buildFailed: 'Build failed', + buildCompleted: 'Build complete', + buildingGraphStatus: 'Building graph', + generatingOntologyStatus: 'Generating ontology', + initializingStatus: 'Initializing', + completed: 'Completed', + inProgress: 'In progress', + pending: 'Pending', + noPendingUpload: 'No pending files were found. Return to the home page and start again.', + uploadingAnalyzing: 'Uploading files and analyzing documents...', + unknownError: 'Unknown error', + requestTimeout: 'The request timed out after 5 minutes. Try smaller documents or check backend model latency.', + backendUnavailable: 'Cannot reach the backend service ({apiBase}). Check whether it is running, and verify proxy/CORS/network configuration.', + backendConfigIncomplete: 'Backend configuration is incomplete: {details}', + missingConfigKey: '{name} is not configured', + ontologyFailed: 'Ontology generation failed', + initFailed: 'Project initialization failed: {message}', + loadFailed: 'Failed to load project', + loadFailedWithMessage: 'Failed to load project: {message}', + processingFailed: 'Processing failed', + defaultProgressMessage: 'Processing...', + startGraphBuild: 'Starting graph build...', + graphTaskStarted: 'Graph build task started...', + startGraphBuildFailed: 'Failed to start graph build', + buildCompletedLoadingGraph: 'Build complete. Loading graph...', + environmentSetupTodo: 'Environment setup is not wired on this screen yet.', + waitingGraphData: 'Waiting for graph data...', + unnamedNode: 'Untitled', + unknownNode: 'Unknown', + }, + step3: { + round: 'ROUND', + elapsedTime: 'Elapsed Time', + acts: 'ACTS', + availableActions: 'Available Actions', + startReport: 'Generate Final Report', + starting: 'Starting...', + totalEvents: 'TOTAL EVENTS', + waitingActions: 'Waiting for agent actions...', + waitingDiagnosticsProcessAlive: 'The simulation worker is still alive but has not emitted any actions yet. Check the live log tail below for startup clues.', + waitingDiagnosticsProcessExited: 'The simulation worker is no longer alive. The backend state should reconcile on the next poll, and the log tail below may explain why it exited.', + waitingDiagnosticsStatus: 'Runner status: {status}', + waitingDiagnosticsPid: 'PID {pid}', + waitingDiagnosticsStatusStarting: 'starting', + waitingDiagnosticsStatusRunning: 'running', + monitor: 'SIMULATION MONITOR', + missingSimulationId: 'Error: missing simulationId', + startingSimulation: 'Starting parallel simulation...', + setMaxRounds: 'Set maximum simulation rounds: {count}', + graphMemoryEnabled: 'Dynamic graph memory updates enabled', + clearedOldLogs: 'Cleared old simulation logs and restarted simulation', + restartSimulation: 'Restart Simulation', + startPreparedSimulation: 'Start Prepared Simulation', + restartPreparedSimulation: 'Restart Prepared Simulation', + resumeRunningSimulation: 'Reattached to the existing simulation run', + resumeCompletedSimulation: 'Loaded the previous simulation timeline', + resumeStoppedSimulation: 'Loaded the stopped simulation state', + resumeFailedSimulation: 'Loaded the failed simulation state: {message}', + replayOnlyLabel: 'Replay only', + replayOnlyNoRun: 'No previous Step 3 run data was found for this history entry. Replay mode will not start a new run automatically.', + replayOnlyNoRunNotice: 'This history entry does not have saved Step 3 timeline data yet. If Step 2 is already prepared, fix the quota/API-key issue and use the button above to start this same simulation without rebuilding the environment.', + replayOnlyFailedNotice: 'This history entry preserves the last failed Step 3 state for inspection. MiroFish cannot continue from the exact stopped point, but after fixing the quota/API-key problem you can use the button above to restart the same prepared simulation.', + replayOnlyStoppedNotice: 'This history entry preserves a stopped Step 3 run. MiroFish cannot continue from the exact stopped point, but you can use the button above to restart the same prepared simulation.', + replayReuseHint: 'After fixing the quota/API-key problem, use the restart button here to reuse the existing Step 2 preparation instead of rebuilding from scratch.', + simulationStarted: 'Simulation engine started', + pidLog: ' ├─ PID: {pid}', + roundProgressLog: '[{platform}] R{currentRound}/{totalRounds} | T:{simulatedHours}h | A:{actionsCount}', + startFailed: 'Start failed', + startFailedWithMessage: 'Start failed: {message}', + startException: 'Start exception: {message}', + stoppingSimulation: 'Stopping simulation...', + simulationStopped: 'Simulation stopped', + stopFailed: 'Stop failed: {message}', + stopException: 'Stop exception: {message}', + allPlatformsEnded: 'Detected that all platforms have finished', + simulationCompleted: 'Simulation completed', + simulationFailed: 'Simulation failed: {message}', + reportAlreadyRequested: 'Report generation has already been requested. Please wait...', + reportPreflightBlocked: 'Step 4 is not available with the current backend configuration: {message}', + interactionShortcutLabel: 'Step 5 Available', + interactionShortcutHint: 'Report generation is blocked, but you can still continue to Step 5 interaction with the prepared simulation. Reason: {message}', + openInteractionShortcutButton: 'Open Step 5 instead', + openInteractionShortcut: 'Opening Step 5 interaction without a report', + reportStarting: 'Starting report generation...', + reportStarted: 'Report generation task started: {id}', + reportStartFailed: 'Failed to start report generation: {message}', + reportStartException: 'Report generation startup exception: {message}', + initLog: 'Step 3 simulation initialized', + platformNames: { + twitter: 'Info Plaza', + reddit: 'Topic Community', + }, + availableActionList: { + post: 'POST', + like: 'LIKE', + repost: 'REPOST', + quote: 'QUOTE', + follow: 'FOLLOW', + idle: 'IDLE', + comment: 'COMMENT', + dislike: 'DISLIKE', + search: 'SEARCH', + trend: 'TREND', + mute: 'MUTE', + refresh: 'REFRESH', + }, + actionTypes: { + post: 'POST', + repost: 'REPOST', + like: 'LIKE', + comment: 'COMMENT', + idle: 'IDLE', + follow: 'FOLLOW', + search: 'SEARCH', + quote: 'QUOTE', + upvote: 'UPVOTE', + downvote: 'DOWNVOTE', + unknown: 'UNKNOWN', + }, + repostedFrom: 'Reposted from @{user}', + likedPost: 'Liked @{user}\'s post', + replyToPost: 'Reply to post #{id}', + searchQuery: 'Search Query:', + followedUser: 'Followed @{user}', + upvotedPost: 'Upvoted Post', + downvotedPost: 'Downvoted Post', + actionSkipped: 'Action Skipped', + unknownUser: 'User', + }, + step4: { + reportTag: 'Prediction Report', + reportId: 'ID: {id}', + reportIdLabel: 'Report ID', + simulationIdLabel: 'Simulation ID', + copyId: 'Copy ID', + copyBundle: 'Copy Bundle', + copied: 'Copied', + exportMd: 'Export MD', + unavailableId: 'Not available yet', + referenceHint: 'Keep these IDs with the exported Markdown or saved history entry so you can compare this report against later real-world outcomes.', + sectionGenerating: 'Generating {title}...', + failedTitle: 'Report generation failed', + retrying: 'Retrying...', + retryReport: 'Retry report generation', + retryShort: 'Retry', + waitingForAgent: 'Waiting for Report Agent...', + metrics: { + sections: 'Sections', + elapsed: 'Elapsed', + tools: 'Tools', + }, + generationStopped: 'Generation stopped', + goToInteraction: 'Go to Deep Interaction', + goToInteractionDirect: 'Continue to Deep Interaction', + interactionShort: 'Step 5', + simulation: 'Simulation', + requirement: 'Requirement', + sectionsPlanned: '{count} sections planned', + toolsLabel: 'Tools', + finalLabel: 'Final', + yes: 'Yes', + no: 'No', + sectionContentGenerated: 'Section "{title}" content generated', + reportGenerationComplete: 'Report generation complete', + hideParams: 'Hide Params', + showParams: 'Show Params', + structuredView: 'Structured View', + rawOutput: 'Raw Output', + hideResponse: 'Hide Response', + showResponse: 'Show Response', + waitingForActivity: 'Waiting for agent activity...', + consoleOutput: 'Console Output', + retryLog: 'Retrying report: {id}', + retryFailed: 'Report retry failed: {message}', + retryException: 'Report retry exception: {message}', + status: { + failed: 'Failed', + completed: 'Completed', + generating: 'Generating...', + waiting: 'Waiting', + }, + failureFallback: 'The backend stopped before the report finished. Retry with the same simulation to generate a fresh report.', + waitingToStart: 'Waiting to start', + planningOutline: 'Planning / Outline', + inProgress: 'In progress', + complete: 'Complete', + finalizing: 'Finalizing', + actions: { + reportStart: 'Report Started', + planning: 'Planning', + planComplete: 'Plan Complete', + sectionStart: 'Section Start', + contentReady: 'Content Ready', + sectionDone: 'Section Done', + toolCall: 'Tool Call', + toolResult: 'Tool Result', + llmResponse: 'LLM Response', + complete: 'Complete', + }, + reportAgentInitialized: 'Report Agent initialized: {id}', + toolNames: { + insightForge: 'Deep Insight', + panoramaSearch: 'Panorama Search', + interviewAgents: 'Agent Interview', + quickSearch: 'Quick Search', + graphStats: 'Graph Stats', + entityQuery: 'Entity Query', + }, + toolDisplay: { + showLess: 'Show Less', + showAll: 'Show all {count} {unit}', + countEntries: '{count} entries', + countItems: '{count} items', + charCount: '{count} chars', + charCountCompact: '{count}k chars', + labels: { + facts: 'Facts', + entities: 'Entities', + relations: 'Relations', + nodes: 'Nodes', + edges: 'Edges', + results: 'Results', + interviewed: 'Interviewed', + total: 'Total', + }, + units: { + entries: 'entries', + items: 'items', + }, + insight: { + scenarioLabel: 'Scenario: ', + tabs: { + facts: 'Current Key Memory ({count})', + entities: 'Core Entities ({count})', + relations: 'Relation Chains ({count})', + subqueries: 'Sub-questions ({count})', + }, + panels: { + facts: 'Latest key facts linked from temporal memory', + entities: 'Core Entities', + relations: 'Relation Chains', + subqueries: 'Generated analytical sub-questions', + }, + empty: { + facts: 'No current key memory', + entities: 'No core entities', + relations: 'No relation chains', + }, + }, + panorama: { + tabs: { + active: 'Active Memory ({count})', + historical: 'Historical Memory ({count})', + entities: 'Involved Entities ({count})', + }, + panels: { + active: 'Active Memory', + historical: 'Historical Memory', + entities: 'Involved Entities', + }, + empty: { + active: 'No active memory', + historical: 'No historical memory', + entities: 'No involved entities', + }, + }, + interview: { + agentIndex: 'Agent {index}', + agentFallback: 'Agent', + selectionReason: 'Selection Reason', + noQuestion: 'No question available', + interviewer: 'Interviewer', + worldOne: 'World 1', + worldTwo: 'World 2', + showMore: 'Show More', + keyQuotes: 'Key Quotes', + summary: 'Interview Summary', + }, + quickSearch: { + searchLabel: 'Search: ', + tabs: { + facts: 'Facts ({count})', + edges: 'Relations ({count})', + nodes: 'Nodes ({count})', + }, + panels: { + results: 'Search Results', + edges: 'Related Relations', + nodes: 'Related Nodes', + }, + empty: { + results: 'No related results found', + }, + }, + }, + }, + graphPanel: { + title: 'Graph Relationship Visualization', + refresh: 'Refresh', + toggleMaximize: 'Maximize or restore', + simulationUpdating: 'GraphRAG memory is updating in real time', + liveUpdating: 'Live updating...', + finishedHint: 'A small amount of content is still processing. Refresh the graph shortly.', + dismissHint: 'Dismiss hint', + nodeDetails: 'Node Details', + relationship: 'Relationship', + name: 'Name', + aliases: 'Merged Aliases', + uuid: 'UUID', + created: 'Created', + properties: 'Properties', + summary: 'Summary', + labels: 'Labels', + selfRelations: 'Self Relations', + itemsCount: '{count} items', + related: 'RELATED', + relatedTo: 'RELATED_TO', + label: 'Label', + type: 'Type', + unknown: 'Unknown', + fact: 'Fact', + episodes: 'Episodes', + validFrom: 'Valid From', + loading: 'Loading graph data...', + waiting: 'Waiting for ontology generation...', + entityTypes: 'Entity Types', + showEdgeLabels: 'Show Edge Labels', + }, + step5: { + predictionReport: 'Prediction Report', + reportId: 'ID: {id}', + sectionGenerating: 'Generating {title}...', + waitingForAgent: 'Waiting for Report Agent...', + interactionOnlyReady: 'Interaction-only mode is ready. The simulation is available even without a Step 4 report.', + interactiveTools: 'Interactive Tools', + agentsAvailable: '{count} agents available', + chatWithReportAgent: 'Chat with Report Agent', + chatWithAnyAgent: 'Chat with any simulated agent', + selectChatTarget: 'Select a chat target', + unknownProfession: 'Unknown profession', + sendSurvey: 'Send Survey', + reportAgentChatTitle: 'Report Agent - Chat', + reportAgentChatSubtitle: 'A quick conversational mode for the report agent with four specialist tools and full MiroFish memory.', + profileBio: 'Bio', + emptyReportAgentChat: 'Chat with Report Agent to explore the report in more detail.', + emptyAgentChat: 'Chat with a simulated agent to understand their perspective.', + you: 'You', + reportAgent: 'Report Agent', + agentFallback: 'Agent', + chatPlaceholder: 'Enter your question...', + selectSurveyTargets: 'Select survey targets', + selectedCount: 'Selected {selected} / {total}', + selectAll: 'Select all', + clear: 'Clear', + surveyQuestion: 'Survey Question', + surveyPlaceholder: 'Enter the question you want to ask every selected agent...', + surveyResults: 'Survey Results', + replyCount: '{count} replies', + chatError: 'Sorry, an error occurred: {message}', + noResponse: 'No response', + requestFailed: 'Request failed', + selectAgentFirst: 'Select a simulated agent first', + historyQuestioner: 'Questioner', + historyYou: 'You', + historyPrompt: 'Here is our previous conversation:\n{history}\n\nMy new question is: {message}', + noResponseData: 'No response data', + interviewEnvReadyBanner: 'Interview environment ready. Available platforms: {platforms}.', + interviewEnvClosedBanner: 'The simulation interview environment is offline. Re-open Step 3 and let the run reach the waiting-for-command state before using Step 5 interviews.', + interviewEnvNoPlatformBanner: 'The simulation environment is running, but no interview platform is currently available.', + interviewEnvClosedError: 'The simulation interview environment is no longer running. Return to Step 3, reopen the simulation, and wait for the environment to enter command mode before retrying.', + interviewPlatformUnavailable: 'The current simulation environment cannot interview these platforms: {platforms}. Reopen the matching platform in Step 3 first.', + interviewTimeoutHintNoSelection: 'Timeout budget: single-agent chat {singleSeconds}s. Survey batches scale with the number of selected agents and stay under the frontend request cap of {requestSeconds}s.', + interviewTimeoutHintWithSelection: 'Timeout budget: single-agent chat {singleSeconds}s. Current {selectedCount}-agent survey batch: {batchSeconds}s, capped by the frontend request budget of {requestSeconds}s.', + interviewTimeoutError: 'The interview timed out before the simulated environment replied. Increase the Step 5 timeout settings or reduce the interview batch size, then retry. Original error: {message}', + platforms: { + reddit: 'Reddit', + twitter: 'Twitter', + }, + tools: { + insightForge: { + name: 'InsightForge', + desc: 'Aligns real-world seed data with the simulated environment and combines global/local memory for deeper causal analysis.', + }, + panoramaSearch: { + name: 'PanoramaSearch', + desc: 'Uses graph traversal to reconstruct propagation paths and capture end-to-end information flow topology.', + }, + quickSearch: { + name: 'QuickSearch', + desc: 'Provides a GraphRAG-backed instant query interface for extracting concrete node attributes and discrete facts quickly.', + }, + interviewSubAgent: { + name: 'InterviewSubAgent', + desc: 'Runs parallel interviews with simulated agents to gather unstructured opinions and mental-state signals.', + }, + }, + logs: { + selectedChatTarget: 'Selected chat target: {name}', + sendFailed: 'Send failed: {message}', + sentToReportAgent: 'Sent to Report Agent: {message}...', + reportAgentReplied: 'Report Agent replied', + sentToAgent: 'Sent to {name}: {message}...', + agentReplied: '{name} replied', + surveySent: 'Sent survey to {count} targets...', + receivedReplies: 'Received {count} replies', + surveyFailed: 'Survey failed: {message}', + loadingReport: 'Loading report data: {id}', + loadReportFailed: 'Failed to load report: {message}', + reportLoaded: 'Report data loaded', + loadReportLogsFailed: 'Failed to load report logs: {message}', + loadedAgents: 'Loaded {count} simulated agents', + loadAgentsFailed: 'Failed to load simulated agents: {message}', + envStatusFailed: 'Failed to refresh interview environment status: {message}', + reopenStep3: 'Opening the saved Step 3 recovery route', + init: 'Step 5 deep interaction initialized', + }, + }, + simulationView: { + preparing: 'Preparing', + logs: { + enterStep3: 'Entering Step 3: Simulation', + customRounds: 'Custom simulation rounds: {count}', + autoRounds: 'Using the auto-generated simulation round count', + envRunningClosing: 'Detected a running simulation environment. Closing it...', + envClosed: 'Simulation environment closed', + closeEnvFailed: 'Failed to close simulation environment: {message}', + closeEnvException: 'Exception while closing simulation environment: {message}', + simRunningStopping: 'Detected a running simulation. Stopping it...', + forceStopped: 'Simulation force-stopped', + forceStopFailed: 'Failed to force-stop simulation: {message}', + forceStopException: 'Force-stop exception: {message}', + loadingSimulation: 'Loading simulation data: {id}', + projectLoaded: 'Project loaded: {id}', + loadSimulationFailed: 'Failed to load simulation data: {message}', + loadException: 'Load exception: {message}', + graphLoaded: 'Graph data loaded', + graphLoadFailed: 'Failed to load graph data: {message}', + init: 'SimulationView initialized', + }, + }, + simulationRunView: { + running: 'Running', + logs: { + preparingGoBack: 'Preparing to return to Step 2. Closing simulation...', + closingEnv: 'Closing simulation environment...', + envClosed: 'Simulation environment closed', + closeEnvFailedTryingForce: 'Failed to close simulation environment. Trying force-stop...', + forceStopped: 'Simulation force-stopped', + forceStopFailed: 'Force-stop failed: {message}', + stoppingProcess: 'Stopping simulation process...', + simStopped: 'Simulation stopped', + stopFailed: 'Stop failed: {message}', + checkStatusFailed: 'Failed to check simulation status: {message}', + enterStep4: 'Entering Step 4: Report Generation', + loadingSimulation: 'Loading simulation data: {id}', + timeConfig: 'Time config: {count} minutes per round', + timeConfigFallback: 'Failed to load time config. Using default: {count} minutes/round', + projectLoaded: 'Project loaded: {id}', + loadSimulationFailed: 'Failed to load simulation data: {message}', + loadException: 'Load exception: {message}', + graphLoaded: 'Graph data loaded', + graphLoadFailed: 'Failed to load graph data: {message}', + graphRefreshStarted: 'Started graph auto-refresh (30s)', + graphRefreshStopped: 'Stopped graph auto-refresh', + init: 'SimulationRunView initialized', + customRounds: 'Custom simulation rounds: {count}', + }, + }, + interactionView: { + logs: { + loadingReport: 'Loading report data: {id}', + loadingSimulation: 'Loading simulation data: {id}', + loadSimulationFailed: 'Failed to load simulation data: {message}', + projectLoaded: 'Project loaded: {id}', + reportInfoFailed: 'Failed to fetch report info: {message}', + loadException: 'Load exception: {message}', + graphLoaded: 'Graph data loaded', + graphLoadFailed: 'Failed to load graph data: {message}', + init: 'InteractionView initialized', + }, + }, + reportView: { + stepName: 'Report Generation', + statusError: 'Error', + statusCompleted: 'Completed', + statusGenerating: 'Generating', + loadingReportData: 'Loading report: {id}', + reportInfoFailed: 'Failed to fetch report info: {message}', + loadException: 'Load exception: {message}', + projectLoaded: 'Project loaded: {id}', + graphLoaded: 'Graph data loaded', + graphLoadFailed: 'Failed to load graph: {message}', + initLog: 'Report view initialized', + }, +} diff --git a/frontend/src/i18n/locales/zh.js b/frontend/src/i18n/locales/zh.js new file mode 100644 index 00000000..9a155bd3 --- /dev/null +++ b/frontend/src/i18n/locales/zh.js @@ -0,0 +1,929 @@ +export default { + common: { + loading: '加载中...', + none: '无', + error: '错误', + completed: '已完成', + processing: '处理中', + }, + nav: { + visitGithub: '访问我们的Github主页', + zh: '中文', + en: 'English', + }, + apiConfig: { + trigger: '后端 API', + title: '前端后端地址', + description: '无需重新打包前端,也可以在运行时改用其他后端 API 地址。留空时会继续使用自动识别或环境变量配置。', + inputLabel: '后端 API 基础地址', + placeholder: 'https://example.com:5001', + save: '应用', + reset: '恢复默认', + saved: '自定义后端地址已保存,新请求会立即使用该地址。', + autoSaved: '已清除自定义后端地址,重新使用自动识别。', + resetDone: '已恢复默认后端目标。', + invalid: '请输入完整的 http:// 或 https:// 后端地址。', + current: '当前目标:', + autoMode: '自动', + customMode: '自定义', + diagnostics: { + title: '后端 LLM 诊断', + description: '读取后端 config-status 接口,直接确认当前是否通过 Codex / OpenAI-compatible 的 OPENAI_* 或项目内 LLM_* 变量完成接入,不必手动查看原始 API 返回。', + loading: '正在检查后端配置...', + refresh: '刷新', + configured: '已检测到后端配置', + configuredOpenAI: '已检测到直接 OPENAI / Codex-compatible 接入', + baseUrlConflictTitle: '检测到后端基础地址冲突', + baseUrlConflictNote: '{configuredEnvNames} 设置了不同的值,当前将使用 {selectedEnv}={selectedValue}。', + zepMissingNote: '直接 LLM 接入已经配置完成,但 Step 1 图谱构建和依赖图谱的报告工具在仓库引入非 Zep 后端之前仍然需要 ZEP_API_KEY。', + nextStepsTitle: '下一条可用路径', + nextStepOpenStep2: '请先进入 Step 2 生成模拟环境,再通过直接后端继续执行 Step 3。', + nextStepReuseStep5: '一旦 Step 2/3 已经产出模拟环境,即使没有 Step 4 报告,也仍然可以继续使用 Step 5 做角色互动。', + nextStepWaitForNonZep: 'Step 1 图谱构建和 Step 4 依赖图谱的报告工具仍会阻塞,直到配置 ZEP_API_KEY 或仓库新增非 Zep 图谱后端。', + incomplete: '后端配置仍需处理', + modeLabel: '后端模式', + sourceLabel: '解析到的配置来源', + envLabel: '生效环境变量', + baseUrlLabel: '后端 LLM 基础地址', + modelLabel: '后端模型', + directLlmLabel: '直接 LLM 使用', + graphBuildLabel: 'Step 1 图谱构建', + reportToolsLabel: 'Step 4 依赖图谱的报告工具', + step5Label: 'Step 5 现有模拟互动', + modeOpenAICompatible: 'OpenAI-compatible', + sourceOpenAIAliases: '直接使用 OPENAI_* 别名', + sourceMixedAliases: '混合使用 OPENAI_* 与 LLM_* 别名', + sourceProjectAliases: '使用项目内 LLM_* 别名', + sourceUnknown: '未解析到', + capabilityReady: '已就绪', + capabilityNeedsZep: '需要 ZEP_API_KEY', + capabilityReadyExistingSimulation: '已有模拟环境时可用', + capabilityNeedsExistingSimulation: '需要现有模拟环境', + capabilityNeedsBackendConfig: '需要先补齐后端配置', + }, + }, + home: { + tagline: '简洁通用的群体智能引擎', + version: '/ v0.1-预览版', + title1: '上传任意报告', + title2: '即刻推演未来', + desc1: '即使只有一段文字,', + desc2: 'MiroFish', + desc3: '也能基于其中的现实种子,全自动生成与之对应的至多', + desc4: '百万级Agent', + desc5: '构成的平行世界。通过上帝视角注入变量,在复杂的群体交互中寻找动态环境下的', + desc6: '“局部最优解”', + desc7: '', + slogan: '让未来在 Agent 群中预演,让决策在百战后胜出', + systemStatus: '系统状态', + ready: '准备就绪', + readyDesc: '预测引擎待命中,可上传多份非结构化数据以初始化模拟序列', + lowCost: '低成本', + lowCostDesc: '常规模拟平均5$/次', + highAvailable: '高可用', + highAvailableDesc: '最多百万级Agent模拟', + workflow: '工作流序列', + step1Title: '图谱构建', + step1Desc: '现实种子提取 & 个体与群体记忆注入 & GraphRAG构建', + step2Title: '环境搭建', + step2Desc: '实体关系抽取 & 人设生成 & 环境配置Agent注入仿真参数', + step3Title: '开始模拟', + step3Desc: '双平台并行模拟 & 自动解析预测需求 & 动态更新时序记忆', + step4Title: '报告生成', + step4Desc: 'ReportAgent拥有丰富的工具集与模拟后环境进行深度交互', + step5Title: '深度互动', + step5Desc: '与模拟世界中的任意一位进行对话 & 与ReportAgent进行对话', + seedLabel: '01 / 现实种子', + formatHint: '支持格式: PDF, MD, TXT', + dragUpload: '拖拽文件上传', + clickBrowse: '或点击浏览文件系统', + firstRunTip: '首次体验建议先用 1 万字以内的材料,并把模拟轮次控制在 30 轮左右,先确认图谱构建和环境启动链路正常。', + inputParams: '输入参数', + promptLabel: '>_ 02 / 模拟提示词', + promptPlaceholder: '// 用自然语言输入模拟或预测需求', + engineBadge: '引擎: MiroFish-V1.0', + startEngine: '启动引擎', + initializing: '初始化中...', + }, + mainView: { + graph: '图谱', + split: '双栏', + workbench: '工作台', + stepGraph: '图谱构建', + stepEnv: '环境搭建', + stepSim: '开始模拟', + stepReport: '报告生成', + stepInteraction: '深度互动', + statusReady: '准备完成', + statusBuilding: '图谱构建中', + statusOntology: '本体生成中', + statusInit: '初始化中', + projectFailed: '项目失败', + logs: { + enterStep: '进入 Step {step}: {name}', + returnStep: '返回 Step {step}: {name}', + customRounds: '自定义模拟轮数: {count} 轮', + init: 'Project 视图已初始化', + noPendingFiles: '新项目流程未找到待处理文件', + uploadingAndAnalyzingDocs: '正在上传并分析文档...', + startOntologyGeneration: '开始生成本体:正在上传文件...', + ontologyGenerated: '项目 {id} 的本体已生成', + ontologyGenerationFailed: '本体生成失败', + ontologyGenerationError: '生成本体时出错: {message}', + newProjectException: '新建项目流程异常: {message}', + loadingProject: '正在加载项目 {id}...', + projectLoaded: '项目加载成功。状态: {status}', + loadProjectError: '加载项目失败: {message}', + loadProjectException: '加载项目异常: {message}', + startingBuild: '开始构建图谱...', + graphBuildTaskStarted: '图谱构建任务已启动。任务 ID: {id}', + startBuildError: '启动图谱构建失败: {message}', + startBuildException: '启动图谱构建异常: {message}', + graphPollingStarted: '开始轮询图谱数据...', + graphDataRefreshed: '图谱数据已刷新。节点: {nodeCount},边: {edgeCount}', + graphBuildCompleted: '图谱构建任务已完成。', + graphBuildFailed: '图谱构建任务失败: {message}', + loadingGraph: '正在加载完整图谱数据: {id}', + graphLoaded: '图谱数据加载成功。', + graphLoadFailed: '加载图谱数据失败: {message}', + graphLoadException: '加载图谱异常: {message}', + graphRefreshTriggered: '已手动触发图谱刷新。', + graphPollingStopped: '图谱轮询已停止。', + }, + }, + history: { + title: '推演记录', + references: '留档引用', + graphBuild: '图谱构建', + envSetup: '环境搭建', + report: '分析报告', + moreFiles: '个文件', + noFiles: '暂无文件', + simRequirement: '模拟需求', + relatedFiles: '关联文件', + noRelatedFiles: '暂无关联文件', + unknownFile: '未知文件', + playback: '推演回放', + graphBuildBtn: '图谱构建', + envSetupBtn: '环境搭建', + simulationRunBtn: '模拟时间线', + reportBtn: '分析报告', + interactionBtn: '深度互动', + deleteRecord: '删除记录', + deleting: '删除中...', + copyId: '复制 ID', + copyBundle: '复制留档包', + copied: '已复制', + exportMd: '导出 MD', + simulationIdLabel: '模拟 ID', + reportIdLabel: '报告 ID', + deleteConfirm: '确认删除 {simulationId} 及其本地历史文件吗?此操作不可撤销。', + deleteFailed: '删除历史记录失败。', + playbackHint: '刷新或关闭浏览器不会直接停止后端任务;可从历史重新打开 Step1、Step2、Step4,以及 Step5 工作区。真正的 Step3 与 Step5 实时互动仍依赖已准备好的运行环境,因此从历史重新进入时也可能只看到恢复引导,而不是立即可用的访谈会话。', + unnamedSimulation: '未命名模拟', + unknownSimulationId: 'SIM_UNKNOWN', + notStarted: '未开始', + roundProgress: '{current}/{total} 轮', + }, + step1Graph: { + ontologyTitle: '本体生成', + completed: '已完成', + generating: '生成中', + pending: '等待', + ontologyDescription: 'LLM 分析文档内容与模拟需求,提取现实种子并自动生成合适的本体结构。', + analyzingDocuments: '正在分析文档...', + detailEntityType: '实体', + detailRelationType: '关系', + detailAttributes: '属性', + detailExamples: '示例', + detailConnections: '连接', + generatedEntityTypes: '已生成实体类型', + generatedRelationTypes: '已生成关系类型', + graphBuildTitle: 'GraphRAG构建', + graphBuildDescription: '基于生成的本体,将文档自动分块后调用 Zep 构建知识图谱,提取实体和关系,并形成时序记忆与社区摘要。', + entityNodes: '实体节点', + relationEdges: '关系边', + schemaTypes: 'SCHEMA类型', + buildCompleteTitle: '构建完成', + inProgress: '进行中', + buildCompleteDescription: '图谱构建已完成,请进入下一步进行模拟环境搭建。', + creatingSimulation: '创建中...', + enterEnvSetup: '进入环境搭建 ->', + systemDashboard: '系统仪表盘', + noProject: 'NO_PROJECT', + missingProjectOrGraph: '缺少项目或图谱信息', + createSimulationFailed: '创建模拟失败', + createSimulationFailedWithMessage: '创建模拟失败: {message}', + createSimulationException: '创建模拟异常', + createSimulationExceptionWithMessage: '创建模拟异常: {message}', + unknownError: '未知错误', + }, + step2: { + instanceInitTitle: '模拟实例初始化', + completed: '已完成', + initializing: '初始化', + waiting: '等待', + generating: '生成中', + orchestrating: '编排中', + inProgress: '进行中', + instanceInitDescription: '新建 simulation 实例,拉取模拟世界参数模版。', + projectIdLabel: '项目 ID', + graphIdLabel: '图谱 ID', + simulationIdLabel: '模拟 ID', + taskIdLabel: '任务 ID', + asyncTaskCompleted: '异步任务已完成', + generateProfilesTitle: '生成 Agent 人设', + generateProfilesDescription: '结合上下文,自动调用工具从知识图谱梳理实体与关系,初始化模拟个体,并基于现实种子赋予他们独特的行为与记忆。', + currentAgentCount: '当前 Agent 数', + expectedAgentCount: '预期 Agent 总数', + relatedTopicsCount: '现实种子当前关联话题数', + generatedProfilesTitle: '已生成的 Agent 人设', + unknownProfileName: '未知', + unknownProfession: '未知职业', + noBio: '暂无简介', + generateConfigTitle: '生成双平台模拟配置', + generateConfigDescription: 'LLM 根据模拟需求与现实种子,智能设置世界时间流速、推荐算法、每个个体的活跃时间段、发言频率、事件触发等参数。', + simulationDuration: '模拟时长', + minutesPerRound: '每轮时长', + totalRounds: '总轮次', + agentsPerHour: '每小时活跃', + peakHours: '高峰时段', + workHours: '工作时段', + morningHours: '早间时段', + offPeakHours: '低谷时段', + agentConfigTitle: 'Agent 配置', + activeHours: '活跃时段', + postsPerHour: '发帖/时', + commentsPerHour: '评论/时', + responseDelay: '响应延迟', + activityLevel: '活跃度', + sentimentBias: '情感倾向', + influenceWeight: '影响力', + recommendationConfigTitle: '推荐算法配置', + platform1Title: '平台 1:广场 / 信息流', + platform2Title: '平台 2:话题 / 社区', + recencyWeight: '时效权重', + popularityWeight: '热度权重', + relevanceWeight: '相关性权重', + viralThreshold: '病毒阈值', + echoChamberStrength: '回音室强度', + llmReasoningTitle: 'LLM 配置推理', + initialActivationTitle: '初始激活编排', + initialActivationDescription: '基于叙事方向,自动生成初始激活事件与热点话题,引导模拟世界的初始状态。', + narrativeDirection: '叙事引导方向', + initialHotTopics: '初始热点话题', + initialActivationSequence: '初始激活序列 ({count})', + readyTitle: '准备完成', + readyDescription: '模拟环境已准备完成,可以开始运行模拟。', + roundConfigTitle: '模拟轮数设定', + roundConfigDescription: 'MiroFish 自动规划推演现实 {hours} 小时,每轮代表现实 {minutes} 分钟时间流逝。', + savedRunLabel: '已保存的 Step 3 状态', + savedRunResumeNotice: '这个模拟已经关联了一个 Step 3 运行。可直接打开已保存的运行界面重新连接实时时间线,无需重新搭建 Step 2。', + savedRunFailedNotice: '这个模拟保留了上一次失败的 Step 3 状态。修正额度或 API Key 问题后,可直接从这里打开并重新启动同一个已准备好的运行,不必重新生成 Step 2。', + savedRunStoppedNotice: '这个模拟保留了一个已停止的 Step 3 状态。你可以直接打开它查看已保存的时间线,或在不重建 Step 2 的情况下重新启动同一个运行。', + savedRunCompletedNotice: '这个模拟已经有完成的 Step 3 时间线。你可以直接打开查看历史运行,或基于同一份 Step 2 准备结果再启动一次。', + savedRunReplayNotice: '这个模拟已经保存过 Step 3 进度。可直接从这里打开并复用现有的 Step 2 准备结果,无需重新搭建。', + openSavedRun: '打开已保存的 Step 3 运行', + restartPreparedRun: '重新启动已准备好的 Step 3 运行', + customMode: '自定义', + roundUnit: '轮', + estimatedRuntime: '若 Agent 规模为 100:预计耗时约 {minutes} 分钟', + recommendedRounds: '40 (推荐)', + firstRunTip: '若首次运行,强烈建议切换至“自定义模式”减少模拟轮数,以便快速预览效果并降低报错风险 ➝', + backToGraph: '← 返回图谱构建', + startSimulation: '开始双世界并行模拟 ➝', + visibleAge: '事件外显年龄', + visibleGender: '事件外显性别', + countryRegion: '国家/地区', + visibleMbti: '事件外显 MBTI', + profileBio: '人设简介', + relatedTopics: '现实种子关联话题', + detailedPersona: '详细人设背景', + systemDashboard: 'SYSTEM DASHBOARD', + noSimulation: 'NO_SIMULATION', + unknownError: '未知错误', + hoursValue: '{value} 小时', + minutesValue: '{value} 分钟', + roundsValue: '{value} 轮', + countValue: '{value} 个', + agentId: 'Agent {id}', + delayRangeMinutes: '{min}-{max} min', + ageValue: '{value} 岁', + gender: { + male: '男', + female: '女', + other: '其他', + }, + personaDimensions: { + eventJourney: { + title: '事件全景经历', + desc: '在此事件中的完整行为轨迹', + }, + behaviorPattern: { + title: '行为模式侧写', + desc: '经验总结与行事风格偏好', + }, + memoryImprint: { + title: '独特记忆印记', + desc: '基于现实种子形成的记忆', + }, + socialGraph: { + title: '社会关系网络', + desc: '个体链接与交互图谱', + }, + }, + logs: { + missingSimulationId: '错误:缺少 simulationId', + instanceCreated: '模拟实例已创建: {id}', + preparingEnvironment: '正在准备模拟环境...', + reusePreparedData: '检测到已有完成的准备工作,直接使用', + prepareTaskStarted: '准备任务已启动', + taskId: ' └─ Task ID: {id}', + entityCount: '从 Zep 图谱读取到 {count} 个实体', + entityTypes: ' └─ 实体类型: {types}', + startPolling: '开始轮询准备进度...', + prepareFailed: '准备失败: {message}', + prepareException: '准备异常: {message}', + prepareCompleted: '✓ 准备工作已完成', + prepareFailedMarked: '✗ 准备失败: {message}', + generatingProfiles: '开始生成 Agent 人设...', + profileProgress: '→ Agent 人设 {current}/{total}: {name} ({profession})', + allProfilesCompleted: '✓ 全部 {count} 个 Agent 人设生成完成', + generatingProfileConfig: '正在生成 Agent 人设配置...', + generatingSimulationConfig: '正在调用 LLM 生成模拟配置参数...', + configGenerated: '✓ 模拟配置生成完成', + summaryAgents: ' ├─ Agent 数量: {count} 个', + summaryHours: ' ├─ 模拟时长: {hours} 小时', + summaryPosts: ' ├─ 初始帖子: {count} 条', + summaryPostsOnly: ' └─ 初始帖子: {count} 条', + summaryTopics: ' ├─ 热点话题: {count} 个', + summaryPlatforms: ' └─ 平台配置: Twitter {twitter}, Reddit {reddit}', + timeConfig: '时间配置: 每轮 {minutes} 分钟, 共 {rounds} 轮', + narrativeDirection: '叙事方向: {narrative}', + environmentReady: '✓ 环境搭建完成,可以开始模拟', + startSimulationCustomRounds: '开始模拟,使用自定义轮数: {count} 轮', + startSimulationAutoRounds: '开始模拟,使用自动配置轮数: {count} 轮', + progressStageWithItems: '[{index}/{stages}] {stage}: {current}/{total} - {item}', + progressStageWithoutItems: '[{index}/{stages}] {stage}: {item}', + loadingPreparedData: '正在加载已有配置数据...', + loadedProfiles: '已加载 {count} 个 Agent 人设', + configLoaded: '✓ 模拟配置加载成功', + configPolling: '配置生成中,开始轮询等待...', + loadConfigFailed: '加载配置失败: {message}', + init: 'Step2 环境搭建初始化', + stageLabels: { + generatingProfiles: '生成 Agent 人设', + generatingConfig: '生成模拟配置', + copyingScripts: '准备模拟脚本', + }, + }, + }, + process: { + navStep: '图谱构建', + graphTitle: '实时知识图谱', + nodes: '节点', + relations: '关系', + refreshGraph: '刷新图谱', + exitFullscreen: '退出全屏', + enterFullscreen: '全屏显示', + liveUpdating: '实时更新中...', + nodeDetails: '节点详情', + relationship: '关系', + name: '名称', + aliases: '合并别名', + uuid: 'UUID', + created: '创建时间', + properties: '属性', + summary: '摘要', + labels: '标签', + label: '标签', + type: '类型', + fact: '事实', + episodes: '事件片段', + validFrom: '生效时间', + invalidAt: '失效时间', + expiredAt: '过期时间', + graphLoading: '图谱数据加载中...', + waitingOntology: '等待本体生成', + waitingOntologyHint: '生成完成后将自动开始构建图谱', + graphBuilding: '图谱构建中', + graphBuildingHint: '数据即将显示...', + processTitle: '构建流程', + apiDescription: '接口说明', + progress: '生成进度', + phase1Title: '本体生成', + phase1Desc: '上传文档后,LLM 会分析文档内容,自动生成适合舆论模拟的本体结构(实体类型 + 关系类型)。', + generatedEntities: '生成的实体类型 ({count})', + generatedRelations: '生成的关系类型 ({count})', + moreRelations: '+{count} 更多关系...', + waitingOntologyShort: '等待本体生成...', + phase2Title: '图谱构建', + phase2Desc: '基于生成的本体,将文档分块后调用 Zep API 构建知识图谱,提取实体和关系。', + waitingOntologyComplete: '等待本体生成完成...', + buildResult: '构建结果', + entityNodes: '实体节点', + relationEdges: '关系边', + entityTypes: '实体类型', + phase3Title: '构建完成', + phase3Desc: '准备进入下一步骤', + nextStep: '进入环境搭建', + projectInfo: '项目信息', + projectName: '项目名称', + projectId: '项目 ID', + graphId: '图谱 ID', + simulationRequirement: '模拟需求', + buildFailed: '构建失败', + buildCompleted: '构建完成', + buildingGraphStatus: '图谱构建中', + generatingOntologyStatus: '本体生成中', + initializingStatus: '初始化中', + completed: '已完成', + inProgress: '进行中', + pending: '等待中', + noPendingUpload: '没有待上传的文件,请返回首页重新操作。', + uploadingAnalyzing: '正在上传文件并分析文档...', + unknownError: '未知错误', + requestTimeout: '请求超时(5分钟),请尝试减小文档体积或检查后端模型响应速度。', + backendUnavailable: '无法连接后端服务({apiBase})。请检查后端是否已启动,以及代理、跨域和网络配置。', + backendConfigIncomplete: '后端配置不完整: {details}', + missingConfigKey: '{name} 未配置', + ontologyFailed: '本体生成失败', + initFailed: '项目初始化失败: {message}', + loadFailed: '加载项目失败', + loadFailedWithMessage: '加载项目失败: {message}', + processingFailed: '处理失败', + defaultProgressMessage: '处理中...', + startGraphBuild: '正在启动图谱构建...', + graphTaskStarted: '图谱构建任务已启动...', + startGraphBuildFailed: '启动图谱构建失败', + buildCompletedLoadingGraph: '构建完成,正在加载图谱...', + environmentSetupTodo: '当前页面尚未接通环境搭建流程。', + waitingGraphData: '等待图谱数据...', + unnamedNode: '未命名', + unknownNode: '未知', + }, + step3: { + round: '轮次', + elapsedTime: '已流逝', + acts: '动作数', + availableActions: '可用动作', + startReport: '开始生成结果报告', + starting: '启动中...', + totalEvents: '事件总数', + waitingActions: '等待智能体动作...', + waitingDiagnosticsProcessAlive: '模拟进程仍在运行,但暂时还没有产出可展示的动作。可先查看下方日志尾部判断当前卡在哪个启动阶段。', + waitingDiagnosticsProcessExited: '模拟进程已经不再存活。后端会在下一次轮询时修正状态,下方日志尾部通常能说明退出原因。', + waitingDiagnosticsStatus: '运行器状态:{status}', + waitingDiagnosticsPid: 'PID {pid}', + waitingDiagnosticsStatusStarting: '启动中', + waitingDiagnosticsStatusRunning: '运行中', + monitor: '模拟监视器', + missingSimulationId: '错误:缺少 simulationId', + startingSimulation: '正在启动双平台并行模拟...', + setMaxRounds: '设置最大模拟轮数: {count}', + graphMemoryEnabled: '已开启动态图谱更新模式', + clearedOldLogs: '已清理旧的模拟日志,重新开始模拟', + restartSimulation: '重新开始模拟', + startPreparedSimulation: '启动已准备好的模拟', + restartPreparedSimulation: '重新启动已准备好的模拟', + resumeRunningSimulation: '已重新连接到现有模拟运行', + resumeCompletedSimulation: '已加载上一次模拟时间线', + resumeStoppedSimulation: '已加载已停止的模拟状态', + resumeFailedSimulation: '已加载失败的模拟状态: {message}', + replayOnlyLabel: '仅回放', + replayOnlyNoRun: '该历史记录没有可回放的 Step 3 运行数据。回放模式不会自动重新启动模拟。', + replayOnlyNoRunNotice: '该历史记录暂时没有保存过 Step 3 时间线。如果 Step 2 环境已经准备好,修正额度或 API Key 问题后,可直接点击上方按钮启动同一个模拟,无需重新搭建环境。', + replayOnlyFailedNotice: '该历史记录保留了上一次失败的 Step 3 状态,方便查看日志和时间线。当前版本还不能从失败点续跑,但修正额度或 API Key 问题后,可直接点击上方按钮重新启动同一个已准备好的模拟。', + replayOnlyStoppedNotice: '该历史记录保留了已停止的 Step 3 状态。当前版本还不能从停止点续跑,但你可以直接点击上方按钮重新启动同一个已准备好的模拟。', + replayReuseHint: '修正额度或 API Key 问题后,可直接使用这里的重启按钮复用现有的 Step 2 准备结果,不必从头重建。', + simulationStarted: '模拟引擎启动成功', + pidLog: ' ├─ PID: {pid}', + roundProgressLog: '[{platform}] R{currentRound}/{totalRounds} | T:{simulatedHours}h | A:{actionsCount}', + startFailed: '启动失败', + startFailedWithMessage: '启动失败: {message}', + startException: '启动异常: {message}', + stoppingSimulation: '正在停止模拟...', + simulationStopped: '模拟已停止', + stopFailed: '停止失败: {message}', + stopException: '停止异常: {message}', + allPlatformsEnded: '检测到所有平台模拟已结束', + simulationCompleted: '模拟已完成', + simulationFailed: '模拟失败: {message}', + reportAlreadyRequested: '报告生成请求已发送,请稍候...', + reportPreflightBlocked: '当前后端配置下暂时无法进入 Step 4:{message}', + interactionShortcutLabel: 'Step 5 可继续', + interactionShortcutHint: '虽然报告生成被阻断,但你仍可基于当前已准备好的模拟直接进入 Step 5 互动。原因:{message}', + openInteractionShortcutButton: '改为进入 Step 5', + openInteractionShortcut: '正在打开无需报告的 Step 5 互动', + reportStarting: '正在启动报告生成...', + reportStarted: '报告生成任务已启动: {id}', + reportStartFailed: '启动报告生成失败: {message}', + reportStartException: '启动报告生成异常: {message}', + initLog: 'Step3 模拟运行初始化', + platformNames: { + twitter: '信息广场', + reddit: '话题社区', + }, + availableActionList: { + post: '发帖', + like: '点赞', + repost: '转发', + quote: '引用', + follow: '关注', + idle: '静默', + comment: '评论', + dislike: '点踩', + search: '搜索', + trend: '趋势', + mute: '静音', + refresh: '刷新', + }, + actionTypes: { + post: '发帖', + repost: '转发', + like: '点赞', + comment: '评论', + idle: '静默', + follow: '关注', + search: '搜索', + quote: '引用', + upvote: '点赞', + downvote: '点踩', + unknown: '未知', + }, + repostedFrom: '转发自 @{user}', + likedPost: '点赞了 @{user} 的帖子', + replyToPost: '回复帖子 #{id}', + searchQuery: '搜索词:', + followedUser: '关注了 @{user}', + upvotedPost: '点赞帖子', + downvotedPost: '点踩帖子', + actionSkipped: '本轮无动作', + unknownUser: '用户', + }, + step4: { + reportTag: '预测报告', + reportId: 'ID: {id}', + reportIdLabel: '报告 ID', + simulationIdLabel: '模拟 ID', + copyId: '复制 ID', + copyBundle: '复制留档包', + copied: '已复制', + exportMd: '导出 MD', + unavailableId: '暂未生成', + referenceHint: '请把这些 ID 与导出的 Markdown 或首页历史记录一起保存,便于后续把本次报告与现实结果做对照复核。', + sectionGenerating: '正在生成 {title}...', + failedTitle: '报告生成失败', + retrying: '重试中...', + retryReport: '重新生成报告', + retryShort: '重试', + waitingForAgent: '等待 Report Agent...', + metrics: { + sections: '章节', + elapsed: '耗时', + tools: '工具', + }, + generationStopped: '生成已停止', + goToInteraction: '进入深度互动', + goToInteractionDirect: '直接进入深度互动', + interactionShort: 'Step 5', + simulation: '模拟', + requirement: '需求', + sectionsPlanned: '已规划 {count} 个章节', + toolsLabel: '工具', + finalLabel: '最终答案', + yes: '是', + no: '否', + sectionContentGenerated: '章节“{title}”内容已生成', + reportGenerationComplete: '报告生成完成', + hideParams: '隐藏参数', + showParams: '查看参数', + structuredView: '结构化视图', + rawOutput: '原始输出', + hideResponse: '隐藏响应', + showResponse: '查看响应', + waitingForActivity: '等待 Agent 活动...', + consoleOutput: '控制台输出', + retryLog: '重新生成报告: {id}', + retryFailed: '重新生成报告失败: {message}', + retryException: '重新生成报告异常: {message}', + status: { + failed: '失败', + completed: '已完成', + generating: '生成中...', + waiting: '等待中', + }, + failureFallback: '后端在报告完成前停止了运行。可基于同一个模拟重新生成一份新报告。', + waitingToStart: '等待开始', + planningOutline: '规划 / 大纲', + inProgress: '进行中', + complete: '完成', + finalizing: '收尾中', + actions: { + reportStart: '报告开始', + planning: '规划中', + planComplete: '规划完成', + sectionStart: '章节开始', + contentReady: '内容就绪', + sectionDone: '章节完成', + toolCall: '工具调用', + toolResult: '工具结果', + llmResponse: 'LLM 响应', + complete: '完成', + }, + reportAgentInitialized: 'Report Agent 已初始化: {id}', + toolNames: { + insightForge: '深度洞察', + panoramaSearch: '全景检索', + interviewAgents: 'Agent 访谈', + quickSearch: '快速检索', + graphStats: '图谱统计', + entityQuery: '实体查询', + }, + toolDisplay: { + showLess: '收起', + showAll: '展开全部 {count} {unit}', + countEntries: '共 {count} 条', + countItems: '共 {count} 个', + charCount: '{count} 字符', + charCountCompact: '{count}k 字符', + labels: { + facts: '事实', + entities: '实体', + relations: '关系', + nodes: '节点', + edges: '边', + results: '结果', + interviewed: '已访谈', + total: '总计', + }, + units: { + entries: '条', + items: '个', + }, + insight: { + scenarioLabel: '预测场景: ', + tabs: { + facts: '当前关键记忆 ({count})', + entities: '核心实体 ({count})', + relations: '关系链 ({count})', + subqueries: '子问题 ({count})', + }, + panels: { + facts: '时序记忆中所关联的最新关键事实', + entities: '核心实体', + relations: '关系链', + subqueries: '漂移查询生成分析子问题', + }, + empty: { + facts: '暂无当前关键记忆', + entities: '暂无核心实体', + relations: '暂无关系链', + }, + }, + panorama: { + tabs: { + active: '当前有效记忆 ({count})', + historical: '历史记忆 ({count})', + entities: '涉及实体 ({count})', + }, + panels: { + active: '当前有效记忆', + historical: '历史记忆', + entities: '涉及实体', + }, + empty: { + active: '暂无当前有效记忆', + historical: '暂无历史记忆', + entities: '暂无涉及实体', + }, + }, + interview: { + agentIndex: 'Agent {index}', + agentFallback: '受访 Agent', + selectionReason: '选择理由', + noQuestion: '暂无问题', + interviewer: '采访者', + worldOne: '世界1', + worldTwo: '世界2', + showMore: '展开更多', + keyQuotes: '关键引言', + summary: '采访摘要', + }, + quickSearch: { + searchLabel: '搜索: ', + tabs: { + facts: '事实 ({count})', + edges: '关系 ({count})', + nodes: '节点 ({count})', + }, + panels: { + results: '搜索结果', + edges: '相关关系', + nodes: '相关节点', + }, + empty: { + results: '未找到相关结果', + }, + }, + }, + }, + graphPanel: { + title: '图谱关系可视化', + refresh: '刷新', + toggleMaximize: '最大化或还原', + simulationUpdating: 'GraphRAG 长短期记忆实时更新中', + liveUpdating: '实时更新中...', + finishedHint: '还有少量内容处理中,建议稍后手动刷新图谱', + dismissHint: '关闭提示', + nodeDetails: '节点详情', + relationship: '关系', + name: '名称', + aliases: '合并别名', + uuid: 'UUID', + created: '创建时间', + properties: '属性', + summary: '摘要', + labels: '标签', + selfRelations: '自关联', + itemsCount: '{count} 项', + related: '关联', + relatedTo: '关联到', + label: '标签', + type: '类型', + unknown: '未知', + fact: '事实', + episodes: '事件片段', + validFrom: '生效时间', + loading: '图谱数据加载中...', + waiting: '等待本体生成...', + entityTypes: '实体类型', + showEdgeLabels: '显示边标签', + }, + step5: { + predictionReport: '预测报告', + reportId: 'ID: {id}', + sectionGenerating: '正在生成 {title}...', + waitingForAgent: '等待 Report Agent...', + interactionOnlyReady: '当前已进入仅互动模式。即使没有 Step 4 报告,也可以直接使用模拟环境继续互动。', + interactiveTools: '交互工具', + agentsAvailable: '{count} 个模拟个体可用', + chatWithReportAgent: '与 Report Agent 对话', + chatWithAnyAgent: '与世界中任意个体对话', + selectChatTarget: '选择对话对象', + unknownProfession: '未知职业', + sendSurvey: '发送问卷', + reportAgentChatTitle: 'Report Agent - 对话', + reportAgentChatSubtitle: '报告生成智能体的快速对话模式,可调用 4 种专业工具,并拥有 MiroFish 的完整记忆。', + profileBio: '简介', + emptyReportAgentChat: '与 Report Agent 对话,深入了解报告内容。', + emptyAgentChat: '与模拟个体对话,了解他们的观点。', + you: '你', + reportAgent: 'Report Agent', + agentFallback: '个体', + chatPlaceholder: '输入您的问题...', + selectSurveyTargets: '选择调查对象', + selectedCount: '已选 {selected} / {total}', + selectAll: '全选', + clear: '清空', + surveyQuestion: '问卷问题', + surveyPlaceholder: '输入您想问所有被选中对象的问题...', + surveyResults: '调查结果', + replyCount: '{count} 条回复', + chatError: '抱歉,发生了错误: {message}', + noResponse: '无响应', + requestFailed: '请求失败', + selectAgentFirst: '请先选择一个模拟个体', + historyQuestioner: '提问者', + historyYou: '你', + historyPrompt: '以下是我们之前的对话:\n{history}\n\n现在我的新问题是:{message}', + noResponseData: '无响应数据', + interviewEnvReadyBanner: '采访环境已就绪,可用平台:{platforms}。', + interviewEnvClosedBanner: '模拟采访环境当前未运行。请先回到 Step 3 重新打开模拟,并等待环境进入可接收命令状态后再使用 Step 5。', + interviewEnvNoPlatformBanner: '模拟环境正在运行,但当前没有可用的采访平台。', + interviewEnvClosedError: '模拟采访环境已经停止。请返回 Step 3 重新打开模拟环境,并等待其进入等待命令状态后再重试。', + interviewPlatformUnavailable: '当前模拟环境无法采访这些平台:{platforms}。请先在 Step 3 重新开启对应平台。', + interviewTimeoutHintNoSelection: '当前超时预算:单个个体对话 {singleSeconds} 秒。批量问卷会随所选对象数量递增,并保持在前端请求上限 {requestSeconds} 秒以内。', + interviewTimeoutHintWithSelection: '当前超时预算:单个个体对话 {singleSeconds} 秒;当前 {selectedCount} 个对象的问卷批次为 {batchSeconds} 秒,且不会超过前端请求预算 {requestSeconds} 秒。', + interviewTimeoutError: '采访请求在模拟环境返回结果前超时。请增大 Step 5 的超时设置或减少单次采访对象数量后重试。原始错误:{message}', + platforms: { + reddit: 'Reddit', + twitter: 'Twitter', + }, + tools: { + insightForge: { + name: 'InsightForge 深度归因', + desc: '对齐现实世界种子数据与模拟环境状态,结合 Global/Local Memory 机制,提供跨时空的深度归因分析。', + }, + panoramaSearch: { + name: 'PanoramaSearch 全景追踪', + desc: '基于图结构的广度遍历算法,重构事件传播路径,捕获全量信息流动的拓扑结构。', + }, + quickSearch: { + name: 'QuickSearch 快速检索', + desc: '基于 GraphRAG 的即时查询接口,优化索引效率,用于快速提取具体节点属性与离散事实。', + }, + interviewSubAgent: { + name: 'InterviewSubAgent 虚拟访谈', + desc: '自主式访谈,可并行与模拟世界中的个体进行多轮对话,采集非结构化观点数据与心理状态。', + }, + }, + logs: { + selectedChatTarget: '选择对话对象: {name}', + sendFailed: '发送失败: {message}', + sentToReportAgent: '向 Report Agent 发送: {message}...', + reportAgentReplied: 'Report Agent 已回复', + sentToAgent: '向 {name} 发送: {message}...', + agentReplied: '{name} 已回复', + surveySent: '发送问卷给 {count} 个对象...', + receivedReplies: '收到 {count} 条回复', + surveyFailed: '问卷发送失败: {message}', + loadingReport: '加载报告数据: {id}', + loadReportFailed: '加载报告失败: {message}', + reportLoaded: '报告数据加载完成', + loadReportLogsFailed: '加载报告日志失败: {message}', + loadedAgents: '加载了 {count} 个模拟个体', + loadAgentsFailed: '加载模拟个体失败: {message}', + envStatusFailed: '刷新采访环境状态失败: {message}', + reopenStep3: '打开已保存的 Step 3 恢复入口', + init: 'Step5 深度互动初始化', + }, + }, + simulationView: { + preparing: '准备中', + logs: { + enterStep3: '进入 Step 3:开始模拟', + customRounds: '自定义模拟轮数: {count} 轮', + autoRounds: '使用自动配置的模拟轮数', + envRunningClosing: '检测到模拟环境正在运行,正在关闭...', + envClosed: '模拟环境已关闭', + closeEnvFailed: '关闭模拟环境失败: {message}', + closeEnvException: '关闭模拟环境异常: {message}', + simRunningStopping: '检测到模拟状态为运行中,正在停止...', + forceStopped: '模拟已强制停止', + forceStopFailed: '强制停止模拟失败: {message}', + forceStopException: '强制停止模拟异常: {message}', + loadingSimulation: '加载模拟数据: {id}', + projectLoaded: '项目加载成功: {id}', + loadSimulationFailed: '加载模拟数据失败: {message}', + loadException: '加载异常: {message}', + graphLoaded: '图谱数据加载成功', + graphLoadFailed: '图谱加载失败: {message}', + init: 'SimulationView 初始化', + }, + }, + simulationRunView: { + running: '运行中', + logs: { + preparingGoBack: '准备返回 Step 2,正在关闭模拟...', + closingEnv: '正在关闭模拟环境...', + envClosed: '模拟环境已关闭', + closeEnvFailedTryingForce: '关闭模拟环境失败,尝试强制停止...', + forceStopped: '模拟已强制停止', + forceStopFailed: '强制停止失败: {message}', + stoppingProcess: '正在停止模拟进程...', + simStopped: '模拟已停止', + stopFailed: '停止模拟失败: {message}', + checkStatusFailed: '检查模拟状态失败: {message}', + enterStep4: '进入 Step 4:报告生成', + loadingSimulation: '加载模拟数据: {id}', + timeConfig: '时间配置: 每轮 {count} 分钟', + timeConfigFallback: '获取时间配置失败,使用默认值: {count}分钟/轮', + projectLoaded: '项目加载成功: {id}', + loadSimulationFailed: '加载模拟数据失败: {message}', + loadException: '加载异常: {message}', + graphLoaded: '图谱数据加载成功', + graphLoadFailed: '图谱加载失败: {message}', + graphRefreshStarted: '开启图谱实时刷新 (30s)', + graphRefreshStopped: '停止图谱实时刷新', + init: 'SimulationRunView 初始化', + customRounds: '自定义模拟轮数: {count}', + }, + }, + interactionView: { + logs: { + loadingReport: '加载报告数据: {id}', + loadingSimulation: '加载模拟数据: {id}', + loadSimulationFailed: '加载模拟数据失败: {message}', + projectLoaded: '项目加载成功: {id}', + reportInfoFailed: '获取报告信息失败: {message}', + loadException: '加载异常: {message}', + graphLoaded: '图谱数据加载成功', + graphLoadFailed: '图谱加载失败: {message}', + init: 'InteractionView 初始化', + }, + }, + reportView: { + stepName: '报告生成', + statusError: '错误', + statusCompleted: '已完成', + statusGenerating: '生成中', + loadingReportData: '加载报告数据: {id}', + reportInfoFailed: '获取报告信息失败: {message}', + loadException: '加载异常: {message}', + projectLoaded: '项目加载成功: {id}', + graphLoaded: '图谱数据加载成功', + graphLoadFailed: '图谱加载失败: {message}', + initLog: 'ReportView 初始化', + }, +} diff --git a/frontend/src/main.js b/frontend/src/main.js index c8e37b03..cc3d101e 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -1,9 +1,11 @@ import { createApp } from 'vue' import App from './App.vue' import router from './router' +import i18n from './i18n' const app = createApp(App) app.use(router) +app.use(i18n) app.mount('#app') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 62d23201..c4b9a493 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -36,6 +36,12 @@ const routes = [ component: ReportView, props: true }, + { + path: '/interaction/simulation/:simulationId', + name: 'InteractionSimulation', + component: InteractionView, + props: true + }, { path: '/interaction/:reportId', name: 'Interaction', diff --git a/frontend/src/utils/clipboard.js b/frontend/src/utils/clipboard.js new file mode 100644 index 00000000..cd81b17b --- /dev/null +++ b/frontend/src/utils/clipboard.js @@ -0,0 +1,12 @@ +export const copyText = async (value, clipboard = globalThis.navigator?.clipboard) => { + if (!value || typeof value !== 'string') { + return false + } + + if (!clipboard || typeof clipboard.writeText !== 'function') { + return false + } + + await clipboard.writeText(value) + return true +} diff --git a/frontend/src/views/Home.vue b/frontend/src/views/Home.vue index afe01a0c..05c5535d 100644 --- a/frontend/src/views/Home.vue +++ b/frontend/src/views/Home.vue @@ -4,8 +4,10 @@ <nav class="navbar"> <div class="nav-brand">MIROFISH</div> <div class="nav-links"> + <LanguageSelector /> + <ApiEndpointControl compact /> <a href="https://github.com/666ghj/MiroFish" target="_blank" class="github-link"> - 访问我们的Github主页 <span class="arrow">↗</span> + {{ t('nav.visitGithub') }} <span class="arrow">↗</span> </a> </div> </nav> @@ -15,21 +17,21 @@ <section class="hero-section"> <div class="hero-left"> <div class="tag-row"> - <span class="orange-tag">简洁通用的群体智能引擎</span> - <span class="version-text">/ v0.1-预览版</span> + <span class="orange-tag">{{ t('home.tagline') }}</span> + <span class="version-text">{{ t('home.version') }}</span> </div> <h1 class="main-title"> - 上传任意报告<br> - <span class="gradient-text">即刻推演未来</span> + {{ t('home.title1') }}<br> + <span class="gradient-text">{{ t('home.title2') }}</span> </h1> <div class="hero-desc"> <p> - 即使只有一段文字,<span class="highlight-bold">MiroFish</span> 也能基于其中的现实种子,全自动生成与之对应的至多<span class="highlight-orange">百万级Agent</span>构成的平行世界。通过上帝视角注入变量,在复杂的群体交互中寻找动态环境下的<span class="highlight-code">“局部最优解”</span> + {{ t('home.desc1') }}<span class="highlight-bold">{{ t('home.desc2') }}</span>{{ t('home.desc3') }}<span class="highlight-orange">{{ t('home.desc4') }}</span>{{ t('home.desc5') }}<span class="highlight-code">{{ t('home.desc6') }}</span>{{ t('home.desc7') }} </p> <p class="slogan-text"> - 让未来在 Agent 群中预演,让决策在百战后胜出<span class="blinking-cursor">_</span> + {{ t('home.slogan') }}<span class="blinking-cursor">_</span> </p> </div> @@ -53,65 +55,65 @@ <!-- 左栏:状态与步骤 --> <div class="left-panel"> <div class="panel-header"> - <span class="status-dot">■</span> 系统状态 + <span class="status-dot">■</span> {{ t('home.systemStatus') }} </div> - <h2 class="section-title">准备就绪</h2> + <h2 class="section-title">{{ t('home.ready') }}</h2> <p class="section-desc"> - 预测引擎待命中,可上传多份非结构化数据以初始化模拟序列 + {{ t('home.readyDesc') }} </p> <!-- 数据指标卡片 --> <div class="metrics-row"> <div class="metric-card"> - <div class="metric-value">低成本</div> - <div class="metric-label">常规模拟平均5$/次</div> + <div class="metric-value">{{ t('home.lowCost') }}</div> + <div class="metric-label">{{ t('home.lowCostDesc') }}</div> </div> <div class="metric-card"> - <div class="metric-value">高可用</div> - <div class="metric-label">最多百万级Agent模拟</div> + <div class="metric-value">{{ t('home.highAvailable') }}</div> + <div class="metric-label">{{ t('home.highAvailableDesc') }}</div> </div> </div> <!-- 项目模拟步骤介绍 (新增区域) --> <div class="steps-container"> <div class="steps-header"> - <span class="diamond-icon">◇</span> 工作流序列 + <span class="diamond-icon">◇</span> {{ t('home.workflow') }} </div> <div class="workflow-list"> <div class="workflow-item"> <span class="step-num">01</span> <div class="step-info"> - <div class="step-title">图谱构建</div> - <div class="step-desc">现实种子提取 & 个体与群体记忆注入 & GraphRAG构建</div> + <div class="step-title">{{ t('home.step1Title') }}</div> + <div class="step-desc">{{ t('home.step1Desc') }}</div> </div> </div> <div class="workflow-item"> <span class="step-num">02</span> <div class="step-info"> - <div class="step-title">环境搭建</div> - <div class="step-desc">实体关系抽取 & 人设生成 & 环境配置Agent注入仿真参数</div> + <div class="step-title">{{ t('home.step2Title') }}</div> + <div class="step-desc">{{ t('home.step2Desc') }}</div> </div> </div> <div class="workflow-item"> <span class="step-num">03</span> <div class="step-info"> - <div class="step-title">开始模拟</div> - <div class="step-desc">双平台并行模拟 & 自动解析预测需求 & 动态更新时序记忆</div> + <div class="step-title">{{ t('home.step3Title') }}</div> + <div class="step-desc">{{ t('home.step3Desc') }}</div> </div> </div> <div class="workflow-item"> <span class="step-num">04</span> <div class="step-info"> - <div class="step-title">报告生成</div> - <div class="step-desc">ReportAgent拥有丰富的工具集与模拟后环境进行深度交互</div> + <div class="step-title">{{ t('home.step4Title') }}</div> + <div class="step-desc">{{ t('home.step4Desc') }}</div> </div> </div> <div class="workflow-item"> <span class="step-num">05</span> <div class="step-info"> - <div class="step-title">深度互动</div> - <div class="step-desc">与模拟世界中的任意一位进行对话 & 与ReportAgent进行对话</div> + <div class="step-title">{{ t('home.step5Title') }}</div> + <div class="step-desc">{{ t('home.step5Desc') }}</div> </div> </div> </div> @@ -124,8 +126,8 @@ <!-- 上传区域 --> <div class="console-section"> <div class="console-header"> - <span class="console-label">01 / 现实种子</span> - <span class="console-meta">支持格式: PDF, MD, TXT</span> + <span class="console-label">{{ t('home.seedLabel') }}</span> + <span class="console-meta">{{ t('home.formatHint') }}</span> </div> <div @@ -148,8 +150,9 @@ <div v-if="files.length === 0" class="upload-placeholder"> <div class="upload-icon">↑</div> - <div class="upload-title">拖拽文件上传</div> - <div class="upload-hint">或点击浏览文件系统</div> + <div class="upload-title">{{ t('home.dragUpload') }}</div> + <div class="upload-hint">{{ t('home.clickBrowse') }}</div> + <div class="upload-tip">{{ t('home.firstRunTip') }}</div> </div> <div v-else class="file-list"> @@ -164,23 +167,23 @@ <!-- 分割线 --> <div class="console-divider"> - <span>输入参数</span> + <span>{{ t('home.inputParams') }}</span> </div> <!-- 输入区域 --> <div class="console-section"> <div class="console-header"> - <span class="console-label">>_ 02 / 模拟提示词</span> + <span class="console-label">{{ t('home.promptLabel') }}</span> </div> <div class="input-wrapper"> <textarea v-model="formData.simulationRequirement" class="code-input" - placeholder="// 用自然语言输入模拟或预测需求(例.武大若发布撤销肖某处分的公告,会引发什么舆情走向)" + :placeholder="t('home.promptPlaceholder')" rows="6" :disabled="loading" ></textarea> - <div class="model-badge">引擎: MiroFish-V1.0</div> + <div class="model-badge">{{ t('home.engineBadge') }}</div> </div> </div> @@ -191,8 +194,8 @@ @click="startSimulation" :disabled="!canSubmit || loading" > - <span v-if="!loading">启动引擎</span> - <span v-else>初始化中...</span> + <span v-if="!loading">{{ t('home.startEngine') }}</span> + <span v-else>{{ t('home.initializing') }}</span> <span class="btn-arrow">→</span> </button> </div> @@ -208,10 +211,14 @@ <script setup> import { ref, computed } from 'vue' +import { useI18n } from 'vue-i18n' import { useRouter } from 'vue-router' import HistoryDatabase from '../components/HistoryDatabase.vue' +import ApiEndpointControl from '../components/ApiEndpointControl.vue' +import LanguageSelector from '../components/LanguageSelector.vue' const router = useRouter() +const { t } = useI18n() // 表单数据 const formData = ref({ @@ -735,6 +742,14 @@ const startSimulation = () => { color: #999; } +.upload-tip { + margin-top: 12px; + font-size: 0.78rem; + line-height: 1.5; + color: #666; + max-width: 320px; +} + .file-list { width: 100%; padding: 15px; diff --git a/frontend/src/views/InteractionView.vue b/frontend/src/views/InteractionView.vue index b153590d..7b8b0081 100644 --- a/frontend/src/views/InteractionView.vue +++ b/frontend/src/views/InteractionView.vue @@ -8,22 +8,23 @@ <div class="header-center"> <div class="view-switcher"> - <button - v-for="mode in ['graph', 'split', 'workbench']" + <button + v-for="mode in ['graph', 'split', 'workbench']" :key="mode" class="switch-btn" :class="{ active: viewMode === mode }" @click="viewMode = mode" > - {{ { graph: '图谱', split: '双栏', workbench: '工作台' }[mode] }} + {{ { graph: t('mainView.graph'), split: t('mainView.split'), workbench: t('mainView.workbench') }[mode] }} </button> </div> </div> <div class="header-right"> + <LanguageSelector light /> <div class="workflow-step"> <span class="step-num">Step 5/5</span> - <span class="step-name">深度互动</span> + <span class="step-name">{{ t('mainView.stepInteraction') }}</span> </div> <div class="step-divider"></div> <span class="status-indicator" :class="statusClass"> @@ -64,26 +65,30 @@ <script setup> import { ref, computed, onMounted, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' +import { useI18n } from 'vue-i18n' import GraphPanel from '../components/GraphPanel.vue' import Step5Interaction from '../components/Step5Interaction.vue' +import LanguageSelector from '../components/LanguageSelector.vue' import { getProject, getGraphData } from '../api/graph' import { getSimulation } from '../api/simulation' import { getReport } from '../api/report' const route = useRoute() const router = useRouter() +const { t } = useI18n() // Props const props = defineProps({ - reportId: String + reportId: String, + simulationId: String }) // Layout State - 默认切换到工作台视角 const viewMode = ref('workbench') // Data State -const currentReportId = ref(route.params.reportId) -const simulationId = ref(null) +const currentReportId = ref(route.params.reportId || props.reportId || null) +const simulationId = ref(route.params.simulationId || props.simulationId || null) const projectData = ref(null) const graphData = ref(null) const graphLoading = ref(false) @@ -109,10 +114,10 @@ const statusClass = computed(() => { }) const statusText = computed(() => { - if (currentStatus.value === 'error') return 'Error' - if (currentStatus.value === 'completed') return 'Completed' - if (currentStatus.value === 'processing') return 'Processing' - return 'Ready' + if (currentStatus.value === 'error') return t('common.error') + if (currentStatus.value === 'completed') return t('common.completed') + if (currentStatus.value === 'processing') return t('common.processing') + return t('mainView.statusReady') }) // --- Helpers --- @@ -138,42 +143,54 @@ const toggleMaximize = (target) => { } // --- Data Logic --- -const loadReportData = async () => { +const loadSimulationContext = async (targetSimulationId) => { + if (!targetSimulationId) { + return + } + + simulationId.value = targetSimulationId + + const simRes = await getSimulation(targetSimulationId) + if (!simRes.success || !simRes.data) { + addLog(t('interactionView.logs.loadSimulationFailed', { message: simRes.error || t('process.unknownError') })) + return + } + + const simData = simRes.data + + if (simData.project_id) { + const projRes = await getProject(simData.project_id) + if (projRes.success && projRes.data) { + projectData.value = projRes.data + addLog(t('interactionView.logs.projectLoaded', { id: projRes.data.project_id })) + + if (projRes.data.graph_id) { + await loadGraph(projRes.data.graph_id) + } + } + } +} + +const loadInteractionData = async () => { try { - addLog(`加载报告数据: ${currentReportId.value}`) - - // 获取 report 信息以获取 simulation_id - const reportRes = await getReport(currentReportId.value) - if (reportRes.success && reportRes.data) { - const reportData = reportRes.data - simulationId.value = reportData.simulation_id - - if (simulationId.value) { - // 获取 simulation 信息 - const simRes = await getSimulation(simulationId.value) - if (simRes.success && simRes.data) { - const simData = simRes.data - - // 获取 project 信息 - if (simData.project_id) { - const projRes = await getProject(simData.project_id) - if (projRes.success && projRes.data) { - projectData.value = projRes.data - addLog(`项目加载成功: ${projRes.data.project_id}`) - - // 获取 graph 数据 - if (projRes.data.graph_id) { - await loadGraph(projRes.data.graph_id) - } - } - } - } + if (currentReportId.value) { + addLog(t('interactionView.logs.loadingReport', { id: currentReportId.value })) + + const reportRes = await getReport(currentReportId.value) + if (reportRes.success && reportRes.data) { + await loadSimulationContext(reportRes.data.simulation_id) + } else { + addLog(t('interactionView.logs.reportInfoFailed', { message: reportRes.error || t('process.unknownError') })) } - } else { - addLog(`获取报告信息失败: ${reportRes.error || '未知错误'}`) + return + } + + if (simulationId.value) { + addLog(t('interactionView.logs.loadingSimulation', { id: simulationId.value })) + await loadSimulationContext(simulationId.value) } } catch (err) { - addLog(`加载异常: ${err.message}`) + addLog(t('interactionView.logs.loadException', { message: err.message })) } } @@ -184,10 +201,10 @@ const loadGraph = async (graphId) => { const res = await getGraphData(graphId) if (res.success) { graphData.value = res.data - addLog('图谱数据加载成功') + addLog(t('interactionView.logs.graphLoaded')) } } catch (err) { - addLog(`图谱加载失败: ${err.message}`) + addLog(t('interactionView.logs.graphLoadFailed', { message: err.message })) } finally { graphLoading.value = false } @@ -201,15 +218,23 @@ const refreshGraph = () => { // Watch route params watch(() => route.params.reportId, (newId) => { - if (newId && newId !== currentReportId.value) { - currentReportId.value = newId - loadReportData() + if (newId !== currentReportId.value) { + currentReportId.value = newId || props.reportId || null + loadInteractionData() + } +}, { immediate: true }) + +watch(() => route.params.simulationId, (newId) => { + if (newId !== simulationId.value) { + simulationId.value = newId || props.simulationId || null + currentReportId.value = route.params.reportId || props.reportId || null + loadInteractionData() } }, { immediate: true }) onMounted(() => { - addLog('InteractionView 初始化') - loadReportData() + addLog(t('interactionView.logs.init')) + loadInteractionData() }) </script> diff --git a/frontend/src/views/MainView.vue b/frontend/src/views/MainView.vue index 6ff29911..e79a8b02 100644 --- a/frontend/src/views/MainView.vue +++ b/frontend/src/views/MainView.vue @@ -15,12 +15,14 @@ :class="{ active: viewMode === mode }" @click="viewMode = mode" > - {{ { graph: '图谱', split: '双栏', workbench: '工作台' }[mode] }} + {{ { graph: t('mainView.graph'), split: t('mainView.split'), workbench: t('mainView.workbench') }[mode] }} </button> </div> </div> <div class="header-right"> + <ApiEndpointControl compact /> + <LanguageSelector light /> <div class="workflow-step"> <span class="step-num">Step {{ currentStep }}/5</span> <span class="step-name">{{ stepNames[currentStep - 1] }}</span> @@ -76,22 +78,34 @@ <script setup> import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue' +import { useI18n } from 'vue-i18n' import { useRoute, useRouter } from 'vue-router' import GraphPanel from '../components/GraphPanel.vue' import Step1GraphBuild from '../components/Step1GraphBuild.vue' import Step2EnvSetup from '../components/Step2EnvSetup.vue' +import ApiEndpointControl from '../components/ApiEndpointControl.vue' +import LanguageSelector from '../components/LanguageSelector.vue' +import { summarizeGraphData } from '../components/graphPanelData.js' import { generateOntology, getProject, buildGraph, getTaskStatus, getGraphData } from '../api/graph' import { getPendingUpload, clearPendingUpload } from '../store/pendingUpload' +import { formatMainViewGraphRefreshLog, formatMainViewStepLog } from './mainViewLogMessages' const route = useRoute() const router = useRouter() +const { t } = useI18n() // Layout State const viewMode = ref('split') // graph | split | workbench // Step State const currentStep = ref(1) // 1: 图谱构建, 2: 环境搭建, 3: 开始模拟, 4: 报告生成, 5: 深度互动 -const stepNames = ['图谱构建', '环境搭建', '开始模拟', '报告生成', '深度互动'] +const stepNames = computed(() => [ + t('mainView.stepGraph'), + t('mainView.stepEnv'), + t('mainView.stepSim'), + t('mainView.stepReport'), + t('mainView.stepInteraction'), +]) // Data State const currentProjectId = ref(route.params.projectId) @@ -130,11 +144,11 @@ const statusClass = computed(() => { }) const statusText = computed(() => { - if (error.value) return 'Error' - if (currentPhase.value >= 2) return 'Ready' - if (currentPhase.value === 1) return 'Building Graph' - if (currentPhase.value === 0) return 'Generating Ontology' - return 'Initializing' + if (error.value) return t('common.error') + if (currentPhase.value >= 2) return t('mainView.statusReady') + if (currentPhase.value === 1) return t('mainView.statusBuilding') + if (currentPhase.value === 0) return t('mainView.statusOntology') + return t('mainView.statusInit') }) // --- Helpers --- @@ -159,11 +173,11 @@ const toggleMaximize = (target) => { const handleNextStep = (params = {}) => { if (currentStep.value < 5) { currentStep.value++ - addLog(`进入 Step ${currentStep.value}: ${stepNames[currentStep.value - 1]}`) + addLog(formatMainViewStepLog('enter', currentStep.value, stepNames.value[currentStep.value - 1], t)) - // 如果是从 Step 2 进入 Step 3,记录模拟轮数配置 + // If Step 2 hands off a custom round count, keep that visible in the local log stream. if (currentStep.value === 3 && params.maxRounds) { - addLog(`自定义模拟轮数: ${params.maxRounds} 轮`) + addLog(t('mainView.logs.customRounds', { count: params.maxRounds })) } } } @@ -171,14 +185,14 @@ const handleNextStep = (params = {}) => { const handleGoBack = () => { if (currentStep.value > 1) { currentStep.value-- - addLog(`返回 Step ${currentStep.value}: ${stepNames[currentStep.value - 1]}`) + addLog(formatMainViewStepLog('back', currentStep.value, stepNames.value[currentStep.value - 1], t)) } } // --- Data Logic --- const initProject = async () => { - addLog('Project view initialized.') + addLog(t('mainView.logs.init')) if (currentProjectId.value === 'new') { await handleNewProject() } else { @@ -189,16 +203,16 @@ const initProject = async () => { const handleNewProject = async () => { const pending = getPendingUpload() if (!pending.isPending || pending.files.length === 0) { - error.value = 'No pending files found.' - addLog('Error: No pending files found for new project.') + error.value = t('mainView.logs.noPendingFiles') + addLog(t('mainView.logs.noPendingFiles')) return } try { loading.value = true currentPhase.value = 0 - ontologyProgress.value = { message: 'Uploading and analyzing docs...' } - addLog('Starting ontology generation: Uploading files...') + ontologyProgress.value = { message: t('mainView.logs.uploadingAndAnalyzingDocs') } + addLog(t('mainView.logs.startOntologyGeneration')) const formData = new FormData() pending.files.forEach(f => formData.append('files', f)) @@ -212,15 +226,15 @@ const handleNewProject = async () => { router.replace({ name: 'Process', params: { projectId: res.data.project_id } }) ontologyProgress.value = null - addLog(`Ontology generated successfully for project ${res.data.project_id}`) + addLog(t('mainView.logs.ontologyGenerated', { id: res.data.project_id })) await startBuildGraph() } else { - error.value = res.error || 'Ontology generation failed' - addLog(`Error generating ontology: ${error.value}`) + error.value = res.error || t('mainView.logs.ontologyGenerationFailed') + addLog(t('mainView.logs.ontologyGenerationError', { message: error.value })) } } catch (err) { error.value = err.message - addLog(`Exception in handleNewProject: ${err.message}`) + addLog(t('mainView.logs.newProjectException', { message: err.message })) } finally { loading.value = false } @@ -229,12 +243,12 @@ const handleNewProject = async () => { const loadProject = async () => { try { loading.value = true - addLog(`Loading project ${currentProjectId.value}...`) + addLog(t('mainView.logs.loadingProject', { id: currentProjectId.value })) const res = await getProject(currentProjectId.value) if (res.success) { projectData.value = res.data updatePhaseByStatus(res.data.status) - addLog(`Project loaded. Status: ${res.data.status}`) + addLog(t('mainView.logs.projectLoaded', { status: res.data.status })) if (res.data.status === 'ontology_generated' && !res.data.graph_id) { await startBuildGraph() @@ -248,11 +262,11 @@ const loadProject = async () => { } } else { error.value = res.error - addLog(`Error loading project: ${res.error}`) + addLog(t('mainView.logs.loadProjectError', { message: res.error })) } } catch (err) { error.value = err.message - addLog(`Exception in loadProject: ${err.message}`) + addLog(t('mainView.logs.loadProjectException', { message: err.message })) } finally { loading.value = false } @@ -264,33 +278,33 @@ const updatePhaseByStatus = (status) => { case 'ontology_generated': currentPhase.value = 0; break; case 'graph_building': currentPhase.value = 1; break; case 'graph_completed': currentPhase.value = 2; break; - case 'failed': error.value = 'Project failed'; break; + case 'failed': error.value = t('mainView.projectFailed'); break; } } const startBuildGraph = async () => { try { currentPhase.value = 1 - buildProgress.value = { progress: 0, message: 'Starting build...' } - addLog('Initiating graph build...') + buildProgress.value = { progress: 0, message: t('mainView.logs.startingBuild') } + addLog(t('mainView.logs.startingBuild')) const res = await buildGraph({ project_id: currentProjectId.value }) if (res.success) { - addLog(`Graph build task started. Task ID: ${res.data.task_id}`) + addLog(t('mainView.logs.graphBuildTaskStarted', { id: res.data.task_id })) startGraphPolling() startPollingTask(res.data.task_id) } else { error.value = res.error - addLog(`Error starting build: ${res.error}`) + addLog(t('mainView.logs.startBuildError', { message: res.error })) } } catch (err) { error.value = err.message - addLog(`Exception in startBuildGraph: ${err.message}`) + addLog(t('mainView.logs.startBuildException', { message: err.message })) } } const startGraphPolling = () => { - addLog('Started polling for graph data...') + addLog(t('mainView.logs.graphPollingStarted')) fetchGraphData() graphPollTimer = setInterval(fetchGraphData, 10000) } @@ -303,9 +317,12 @@ const fetchGraphData = async () => { const gRes = await getGraphData(projRes.data.graph_id) if (gRes.success) { graphData.value = gRes.data - const nodeCount = gRes.data.node_count || gRes.data.nodes?.length || 0 - const edgeCount = gRes.data.edge_count || gRes.data.edges?.length || 0 - addLog(`Graph data refreshed. Nodes: ${nodeCount}, Edges: ${edgeCount}`) + const { nodeCount, edgeCount } = summarizeGraphData({ + graphData: gRes.data, + unnamedNodeLabel: t('common.unnamed'), + unknownNodeLabel: t('graphPanel.unknown'), + }) + addLog(formatMainViewGraphRefreshLog(nodeCount, edgeCount, t)) } } } catch (err) { @@ -332,7 +349,7 @@ const pollTaskStatus = async (taskId) => { buildProgress.value = { progress: task.progress || 0, message: task.message } if (task.status === 'completed') { - addLog('Graph build task completed.') + addLog(t('mainView.logs.graphBuildCompleted')) stopPolling() stopGraphPolling() // Stop polling, do final load currentPhase.value = 2 @@ -346,7 +363,7 @@ const pollTaskStatus = async (taskId) => { } else if (task.status === 'failed') { stopPolling() error.value = task.error - addLog(`Graph build task failed: ${task.error}`) + addLog(t('mainView.logs.graphBuildFailed', { message: task.error })) } } } catch (e) { @@ -356,17 +373,17 @@ const pollTaskStatus = async (taskId) => { const loadGraph = async (graphId) => { graphLoading.value = true - addLog(`Loading full graph data: ${graphId}`) + addLog(t('mainView.logs.loadingGraph', { id: graphId })) try { const res = await getGraphData(graphId) if (res.success) { graphData.value = res.data - addLog('Graph data loaded successfully.') + addLog(t('mainView.logs.graphLoaded')) } else { - addLog(`Failed to load graph data: ${res.error}`) + addLog(t('mainView.logs.graphLoadFailed', { message: res.error })) } } catch (e) { - addLog(`Exception loading graph: ${e.message}`) + addLog(t('mainView.logs.graphLoadException', { message: e.message })) } finally { graphLoading.value = false } @@ -374,7 +391,7 @@ const loadGraph = async (graphId) => { const refreshGraph = () => { if (projectData.value?.graph_id) { - addLog('Manual graph refresh triggered.') + addLog(t('mainView.logs.graphRefreshTriggered')) loadGraph(projectData.value.graph_id) } } @@ -390,7 +407,7 @@ const stopGraphPolling = () => { if (graphPollTimer) { clearInterval(graphPollTimer) graphPollTimer = null - addLog('Graph polling stopped.') + addLog(t('mainView.logs.graphPollingStopped')) } } diff --git a/frontend/src/views/Process.vue b/frontend/src/views/Process.vue index 2d2d3cc1..ace4f0df 100644 --- a/frontend/src/views/Process.vue +++ b/frontend/src/views/Process.vue @@ -7,7 +7,7 @@ <!-- 中间步骤指示器 --> <div class="nav-center"> <div class="step-badge">STEP 01</div> - <div class="step-name">图谱构建</div> + <div class="step-name">{{ t('process.navStep') }}</div> </div> <div class="nav-status"> @@ -23,20 +23,20 @@ <div class="panel-header"> <div class="header-left"> <span class="header-deco">◆</span> - <span class="header-title">实时知识图谱</span> + <span class="header-title">{{ t('process.graphTitle') }}</span> </div> <div class="header-right"> <template v-if="graphData"> - <span class="stat-item">{{ graphData.node_count || graphData.nodes?.length || 0 }} 节点</span> + <span class="stat-item">{{ displayedNodeCount }} {{ t('process.nodes') }}</span> <span class="stat-divider">|</span> - <span class="stat-item">{{ graphData.edge_count || graphData.edges?.length || 0 }} 关系</span> + <span class="stat-item">{{ displayedEdgeCount }} {{ t('process.relations') }}</span> <span class="stat-divider">|</span> </template> <div class="action-buttons"> - <button class="action-btn" @click="refreshGraph" :disabled="graphLoading" title="刷新图谱"> + <button class="action-btn" @click="refreshGraph" :disabled="graphLoading" :title="t('process.refreshGraph')"> <span class="icon-refresh" :class="{ 'spinning': graphLoading }">↻</span> </button> - <button class="action-btn" @click="toggleFullScreen" :title="isFullScreen ? '退出全屏' : '全屏显示'"> + <button class="action-btn" @click="toggleFullScreen" :title="isFullScreen ? t('process.exitFullscreen') : t('process.enterFullscreen')"> <span class="icon-fullscreen">{{ isFullScreen ? '↙' : '↗' }}</span> </button> </div> @@ -50,13 +50,13 @@ <!-- 构建中提示 --> <div v-if="currentPhase === 1" class="graph-building-hint"> <span class="building-dot"></span> - 实时更新中... + {{ t('process.liveUpdating') }} </div> <!-- 节点/边详情面板 --> <div v-if="selectedItem" class="detail-panel"> <div class="detail-panel-header"> - <span class="detail-title">{{ selectedItem.type === 'node' ? 'Node Details' : 'Relationship' }}</span> + <span class="detail-title">{{ selectedItem.type === 'node' ? t('process.nodeDetails') : t('process.relationship') }}</span> <span v-if="selectedItem.type === 'node'" class="detail-badge" :style="{ background: selectedItem.color }"> {{ selectedItem.entityType }} </span> @@ -66,21 +66,28 @@ <!-- 节点详情 --> <div v-if="selectedItem.type === 'node'" class="detail-content"> <div class="detail-row"> - <span class="detail-label">Name:</span> + <span class="detail-label">{{ t('process.name') }}:</span> <span class="detail-value highlight">{{ selectedItem.data.name }}</span> </div> <div class="detail-row"> - <span class="detail-label">UUID:</span> + <span class="detail-label">{{ t('process.uuid') }}:</span> <span class="detail-value uuid">{{ selectedItem.data.uuid }}</span> </div> <div class="detail-row" v-if="selectedItem.data.created_at"> - <span class="detail-label">Created:</span> + <span class="detail-label">{{ t('process.created') }}:</span> <span class="detail-value">{{ formatDate(selectedItem.data.created_at) }}</span> </div> + + <div class="detail-row" v-if="selectedNodeAliasNames.length"> + <span class="detail-label">{{ t('process.aliases') }}:</span> + <div class="detail-labels"> + <span v-for="alias in selectedNodeAliasNames" :key="alias" class="label-tag">{{ alias }}</span> + </div> + </div> <!-- Properties / Attributes --> <div class="detail-section" v-if="selectedItem.data.attributes && Object.keys(selectedItem.data.attributes).length > 0"> - <span class="detail-label">Properties:</span> + <span class="detail-label">{{ t('process.properties') }}:</span> <div class="properties-list"> <div v-for="(value, key) in selectedItem.data.attributes" :key="key" class="property-item"> <span class="property-key">{{ key }}:</span> @@ -91,13 +98,13 @@ <!-- Summary --> <div class="detail-section" v-if="selectedItem.data.summary"> - <span class="detail-label">Summary:</span> + <span class="detail-label">{{ t('process.summary') }}:</span> <p class="detail-summary">{{ selectedItem.data.summary }}</p> </div> <!-- Labels --> <div class="detail-row" v-if="selectedItem.data.labels?.length"> - <span class="detail-label">Labels:</span> + <span class="detail-label">{{ t('process.labels') }}:</span> <div class="detail-labels"> <span v-for="label in selectedItem.data.labels" :key="label" class="label-tag">{{ label }}</span> </div> @@ -115,49 +122,49 @@ <span class="edge-target">{{ selectedItem.data.target_name || selectedItem.data.target_node_name }}</span> </div> - <div class="detail-subtitle">Relationship</div> + <div class="detail-subtitle">{{ t('process.relationship') }}</div> <div class="detail-row"> - <span class="detail-label">UUID:</span> + <span class="detail-label">{{ t('process.uuid') }}:</span> <span class="detail-value uuid">{{ selectedItem.data.uuid }}</span> </div> <div class="detail-row"> - <span class="detail-label">Label:</span> + <span class="detail-label">{{ t('process.label') }}:</span> <span class="detail-value">{{ selectedItem.data.name || selectedItem.data.fact_type || 'RELATED_TO' }}</span> </div> <div class="detail-row" v-if="selectedItem.data.fact_type"> - <span class="detail-label">Type:</span> + <span class="detail-label">{{ t('process.type') }}:</span> <span class="detail-value">{{ selectedItem.data.fact_type }}</span> </div> <!-- Fact --> <div class="detail-section" v-if="selectedItem.data.fact"> - <span class="detail-label">Fact:</span> + <span class="detail-label">{{ t('process.fact') }}:</span> <p class="detail-summary">{{ selectedItem.data.fact }}</p> </div> <!-- Episodes --> <div class="detail-section" v-if="selectedItem.data.episodes?.length"> - <span class="detail-label">Episodes:</span> + <span class="detail-label">{{ t('process.episodes') }}:</span> <div class="episodes-list"> <span v-for="ep in selectedItem.data.episodes" :key="ep" class="episode-tag">{{ ep }}</span> </div> </div> <div class="detail-row" v-if="selectedItem.data.created_at"> - <span class="detail-label">Created:</span> + <span class="detail-label">{{ t('process.created') }}:</span> <span class="detail-value">{{ formatDate(selectedItem.data.created_at) }}</span> </div> <div class="detail-row" v-if="selectedItem.data.valid_at"> - <span class="detail-label">Valid From:</span> + <span class="detail-label">{{ t('process.validFrom') }}:</span> <span class="detail-value">{{ formatDate(selectedItem.data.valid_at) }}</span> </div> <div class="detail-row" v-if="selectedItem.data.invalid_at"> - <span class="detail-label">Invalid At:</span> + <span class="detail-label">{{ t('process.invalidAt') }}:</span> <span class="detail-value">{{ formatDate(selectedItem.data.invalid_at) }}</span> </div> <div class="detail-row" v-if="selectedItem.data.expired_at"> - <span class="detail-label">Expired At:</span> + <span class="detail-label">{{ t('process.expiredAt') }}:</span> <span class="detail-value">{{ formatDate(selectedItem.data.expired_at) }}</span> </div> </div> @@ -171,7 +178,7 @@ <div class="loading-ring"></div> <div class="loading-ring"></div> </div> - <p class="loading-text">图谱数据加载中...</p> + <p class="loading-text">{{ t('process.graphLoading') }}</p> </div> <!-- 等待构建 --> @@ -189,8 +196,8 @@ <line x1="50" y1="72" x2="74" y2="66" stroke="#000" stroke-width="1"/> </svg> </div> - <p class="waiting-text">等待本体生成</p> - <p class="waiting-hint">生成完成后将自动开始构建图谱</p> + <p class="waiting-text">{{ t('process.waitingOntology') }}</p> + <p class="waiting-hint">{{ t('process.waitingOntologyHint') }}</p> </div> <!-- 构建中但还没有数据 --> @@ -200,8 +207,8 @@ <div class="loading-ring"></div> <div class="loading-ring"></div> </div> - <p class="waiting-text">图谱构建中</p> - <p class="waiting-hint">数据即将显示...</p> + <p class="waiting-text">{{ t('process.graphBuilding') }}</p> + <p class="waiting-hint">{{ t('process.graphBuildingHint') }}</p> </div> <!-- 错误状态 --> @@ -225,7 +232,7 @@ <div class="right-panel" :class="{ 'hidden': isFullScreen }"> <div class="panel-header dark-header"> <span class="header-icon">▣</span> - <span class="header-title">构建流程</span> + <span class="header-title">{{ t('process.processTitle') }}</span> </div> <div class="process-content"> @@ -234,7 +241,7 @@ <div class="phase-header"> <span class="phase-num">01</span> <div class="phase-info"> - <div class="phase-title">本体生成</div> + <div class="phase-title">{{ t('process.phase1Title') }}</div> <div class="phase-api">/api/graph/ontology/generate</div> </div> <span class="phase-status" :class="getPhaseStatusClass(0)"> @@ -244,15 +251,13 @@ <div class="phase-detail"> <div class="detail-section"> - <div class="detail-label">接口说明</div> - <div class="detail-content"> - 上传文档后,LLM分析文档内容,自动生成适合舆论模拟的本体结构(实体类型 + 关系类型) - </div> + <div class="detail-label">{{ t('process.apiDescription') }}</div> + <div class="detail-content">{{ t('process.phase1Desc') }}</div> </div> <!-- 本体生成进度 --> <div class="detail-section" v-if="ontologyProgress && currentPhase === 0"> - <div class="detail-label">生成进度</div> + <div class="detail-label">{{ t('process.progress') }}</div> <div class="ontology-progress"> <div class="progress-spinner"></div> <span class="progress-text">{{ ontologyProgress.message }}</span> @@ -261,7 +266,7 @@ <!-- 已生成的本体信息 --> <div class="detail-section" v-if="projectData?.ontology"> - <div class="detail-label">生成的实体类型 ({{ projectData.ontology.entity_types?.length || 0 }})</div> + <div class="detail-label">{{ t('process.generatedEntities', { count: projectData.ontology.entity_types?.length || 0 }) }}</div> <div class="entity-tags"> <span v-for="entity in projectData.ontology.entity_types" @@ -274,7 +279,7 @@ </div> <div class="detail-section" v-if="projectData?.ontology"> - <div class="detail-label">生成的关系类型 ({{ projectData.ontology.relation_types?.length || 0 }})</div> + <div class="detail-label">{{ t('process.generatedRelations', { count: projectData.ontology.relation_types?.length || 0 }) }}</div> <div class="relation-list"> <div v-for="(rel, idx) in projectData.ontology.relation_types?.slice(0, 5) || []" @@ -288,14 +293,14 @@ <span class="rel-target">{{ rel.target_type }}</span> </div> <div v-if="(projectData.ontology.relation_types?.length || 0) > 5" class="relation-more"> - +{{ projectData.ontology.relation_types.length - 5 }} 更多关系... + {{ t('process.moreRelations', { count: projectData.ontology.relation_types.length - 5 }) }} </div> </div> </div> <!-- 等待状态 --> <div class="detail-section waiting-state" v-if="!projectData?.ontology && currentPhase === 0 && !ontologyProgress"> - <div class="waiting-hint">等待本体生成...</div> + <div class="waiting-hint">{{ t('process.waitingOntologyShort') }}</div> </div> </div> </div> @@ -305,7 +310,7 @@ <div class="phase-header"> <span class="phase-num">02</span> <div class="phase-info"> - <div class="phase-title">图谱构建</div> + <div class="phase-title">{{ t('process.phase2Title') }}</div> <div class="phase-api">/api/graph/build</div> </div> <span class="phase-status" :class="getPhaseStatusClass(1)"> @@ -315,20 +320,18 @@ <div class="phase-detail"> <div class="detail-section"> - <div class="detail-label">接口说明</div> - <div class="detail-content"> - 基于生成的本体,将文档分块后调用 Zep API 构建知识图谱,提取实体和关系 - </div> + <div class="detail-label">{{ t('process.apiDescription') }}</div> + <div class="detail-content">{{ t('process.phase2Desc') }}</div> </div> <!-- 等待本体完成 --> <div class="detail-section waiting-state" v-if="currentPhase < 1"> - <div class="waiting-hint">等待本体生成完成...</div> + <div class="waiting-hint">{{ t('process.waitingOntologyComplete') }}</div> </div> <!-- 构建进度 --> <div class="detail-section" v-if="buildProgress && currentPhase >= 1"> - <div class="detail-label">构建进度</div> + <div class="detail-label">{{ t('process.progress') }}</div> <div class="progress-bar"> <div class="progress-fill" :style="{ width: buildProgress.progress + '%' }"></div> </div> @@ -339,19 +342,19 @@ </div> <div class="detail-section" v-if="graphData"> - <div class="detail-label">构建结果</div> + <div class="detail-label">{{ t('process.buildResult') }}</div> <div class="build-result"> <div class="result-item"> - <span class="result-value">{{ graphData.node_count }}</span> - <span class="result-label">实体节点</span> + <span class="result-value">{{ displayedNodeCount }}</span> + <span class="result-label">{{ t('process.entityNodes') }}</span> </div> <div class="result-item"> - <span class="result-value">{{ graphData.edge_count }}</span> - <span class="result-label">关系边</span> + <span class="result-value">{{ displayedEdgeCount }}</span> + <span class="result-label">{{ t('process.relationEdges') }}</span> </div> <div class="result-item"> <span class="result-value">{{ entityTypes.length }}</span> - <span class="result-label">实体类型</span> + <span class="result-label">{{ t('process.entityTypes') }}</span> </div> </div> </div> @@ -363,8 +366,8 @@ <div class="phase-header"> <span class="phase-num">03</span> <div class="phase-info"> - <div class="phase-title">构建完成</div> - <div class="phase-api">准备进入下一步骤</div> + <div class="phase-title">{{ t('process.phase3Title') }}</div> + <div class="phase-api">{{ t('process.phase3Desc') }}</div> </div> <span class="phase-status" :class="getPhaseStatusClass(2)"> {{ getPhaseStatusText(2) }} @@ -375,7 +378,7 @@ <!-- 下一步按钮 --> <div class="next-step-section" v-if="currentPhase >= 2"> <button class="next-step-btn" @click="goToNextStep" :disabled="currentPhase < 2"> - 进入环境搭建 + {{ t('process.nextStep') }} <span class="btn-arrow">→</span> </button> </div> @@ -385,23 +388,23 @@ <div class="project-panel"> <div class="project-header"> <span class="project-icon">◇</span> - <span class="project-title">项目信息</span> + <span class="project-title">{{ t('process.projectInfo') }}</span> </div> <div class="project-details" v-if="projectData"> <div class="project-item"> - <span class="item-label">项目名称</span> + <span class="item-label">{{ t('process.projectName') }}</span> <span class="item-value">{{ projectData.name }}</span> </div> <div class="project-item"> - <span class="item-label">项目ID</span> + <span class="item-label">{{ t('process.projectId') }}</span> <span class="item-value code">{{ projectData.project_id }}</span> </div> <div class="project-item" v-if="projectData.graph_id"> - <span class="item-label">图谱ID</span> + <span class="item-label">{{ t('process.graphId') }}</span> <span class="item-value code">{{ projectData.graph_id }}</span> </div> <div class="project-item"> - <span class="item-label">模拟需求</span> + <span class="item-label">{{ t('process.simulationRequirement') }}</span> <span class="item-value">{{ projectData.simulation_requirement || '-' }}</span> </div> </div> @@ -413,13 +416,20 @@ <script setup> import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue' +import { useI18n } from 'vue-i18n' import { useRoute, useRouter } from 'vue-router' -import { generateOntology, getProject, buildGraph, getTaskStatus, getGraphData } from '../api/graph' +import { generateOntology, getProject, buildGraph, getTaskStatus, getGraphData, getBackendConfigStatus } from '../api/graph' +import { formatApiError } from '../api/errors' +import { resolveBaseURL } from '../api/index.js' import { getPendingUpload, clearPendingUpload } from '../store/pendingUpload' +import { getDisplayedAliasNames } from '../components/graphAliasDetails.js' +import { summarizeGraphData } from '../components/graphPanelData.js' +import { getProcessGraphSignature, mapProcessGraphData } from './processGraphData.js' import * as d3 from 'd3' const route = useRoute() const router = useRouter() +const { t, locale } = useI18n() // 当前项目ID(可能从'new'变为实际ID) const currentProjectId = ref(route.params.projectId) @@ -430,10 +440,25 @@ const graphLoading = ref(false) const error = ref('') const projectData = ref(null) const graphData = ref(null) +const graphSignature = ref('') const buildProgress = ref(null) const ontologyProgress = ref(null) // 本体生成进度 const currentPhase = ref(-1) // -1: 上传中, 0: 本体生成中, 1: 图谱构建, 2: 完成 const selectedItem = ref(null) // 选中的节点或边 +const selectedNodeAliasNames = computed(() => + selectedItem.value?.type === 'node' + ? getDisplayedAliasNames(selectedItem.value.data) + : [], +) +const normalizedGraphSummary = computed(() => + summarizeGraphData({ + graphData: graphData.value, + unnamedNodeLabel: t('process.unnamedNode'), + unknownNodeLabel: t('process.unknownNode'), + }), +) +const displayedNodeCount = computed(() => normalizedGraphSummary.value.nodeCount) +const displayedEdgeCount = computed(() => normalizedGraphSummary.value.edgeCount) const isFullScreen = ref(false) // DOM引用 @@ -451,28 +476,19 @@ const statusClass = computed(() => { }) const statusText = computed(() => { - if (error.value) return '构建失败' - if (currentPhase.value >= 2) return '构建完成' - if (currentPhase.value === 1) return '图谱构建中' - if (currentPhase.value === 0) return '本体生成中' - return '初始化中' + if (error.value) return t('process.buildFailed') + if (currentPhase.value >= 2) return t('process.buildCompleted') + if (currentPhase.value === 1) return t('process.buildingGraphStatus') + if (currentPhase.value === 0) return t('process.generatingOntologyStatus') + return t('process.initializingStatus') }) -const entityTypes = computed(() => { - if (!graphData.value?.nodes) return [] - - const typeMap = {} - const colors = ['#FF6B35', '#004E89', '#7B2D8E', '#1A936F', '#C5283D', '#E9724C'] - - graphData.value.nodes.forEach(node => { - const type = node.labels?.find(l => l !== 'Entity') || 'Entity' - if (!typeMap[type]) { - typeMap[type] = { name: type, count: 0, color: colors[Object.keys(typeMap).length % colors.length] } - } - typeMap[type].count++ - }) - - return Object.values(typeMap) +const entityTypes = computed(() => normalizedGraphSummary.value.entityTypes) +const buildGraphSignature = (payload) => getProcessGraphSignature({ + nodes: payload?.nodes || [], + edges: payload?.edges || [], + unnamedNodeLabel: t('process.unnamedNode'), + unknownNodeLabel: t('process.unknownNode'), }) // 方法 @@ -482,7 +498,7 @@ const goHome = () => { const goToNextStep = () => { // TODO: 进入环境搭建步骤 - alert('环境搭建功能开发中...') + alert(t('process.environmentSetupTodo')) } const toggleFullScreen = () => { @@ -503,7 +519,7 @@ const formatDate = (dateStr) => { if (!dateStr) return '-' try { const date = new Date(dateStr) - return date.toLocaleString('zh-CN', { + return date.toLocaleString(locale.value === 'en' ? 'en-US' : 'zh-CN', { year: 'numeric', month: 'short', day: 'numeric', @@ -540,14 +556,14 @@ const getPhaseStatusClass = (phase) => { } const getPhaseStatusText = (phase) => { - if (currentPhase.value > phase) return '已完成' + if (currentPhase.value > phase) return t('process.completed') if (currentPhase.value === phase) { if (phase === 1 && buildProgress.value) { return `${buildProgress.value.progress}%` } - return '进行中' + return t('process.inProgress') } - return '等待中' + return t('process.pending') } // 初始化 - 处理新建项目或加载已有项目 @@ -564,12 +580,16 @@ const initProject = async () => { } } +const ensureBackendConfigReady = async () => { + await getBackendConfigStatus() +} + // 处理新建项目 - 调用 ontology/generate API const handleNewProject = async () => { const pending = getPendingUpload() if (!pending.isPending || pending.files.length === 0) { - error.value = '没有待上传的文件,请返回首页重新操作' + error.value = t('process.noPendingUpload') loading.value = false return } @@ -577,7 +597,8 @@ const handleNewProject = async () => { try { loading.value = true currentPhase.value = 0 // 本体生成阶段 - ontologyProgress.value = { message: '正在上传文件并分析文档...' } + ontologyProgress.value = { message: t('process.uploadingAnalyzing') } + await ensureBackendConfigReady() // 构建 FormData const formDataObj = new FormData() @@ -608,11 +629,18 @@ const handleNewProject = async () => { // 自动开始图谱构建 await startBuildGraph() } else { - error.value = response.error || '本体生成失败' + error.value = response.error || t('process.ontologyFailed') } } catch (err) { console.error('Handle new project error:', err) - error.value = '项目初始化失败: ' + (err.message || '未知错误') + error.value = t('process.initFailed', { + message: formatApiError({ + err, + t, + resolveBaseURL, + locationOrigin: window.location.origin + }) + }) } finally { loading.value = false } @@ -645,11 +673,11 @@ const loadProject = async () => { await loadGraph(response.data.graph_id) } } else { - error.value = response.error || '加载项目失败' + error.value = response.error || t('process.loadFailed') } } catch (err) { console.error('Load project error:', err) - error.value = '加载项目失败: ' + (err.message || '未知错误') + error.value = t('process.loadFailedWithMessage', { message: err.message || t('process.unknownError') }) } finally { loading.value = false } @@ -668,7 +696,7 @@ const updatePhaseByStatus = (status) => { currentPhase.value = 2 break case 'failed': - error.value = projectData.value?.error || '处理失败' + error.value = projectData.value?.error || t('process.processingFailed') break } } @@ -677,16 +705,17 @@ const updatePhaseByStatus = (status) => { const startBuildGraph = async () => { try { currentPhase.value = 1 + await ensureBackendConfigReady() // 设置初始进度 buildProgress.value = { progress: 0, - message: '正在启动图谱构建...' + message: t('process.startGraphBuild') } const response = await buildGraph({ project_id: currentProjectId.value }) if (response.success) { - buildProgress.value.message = '图谱构建任务已启动...' + buildProgress.value.message = t('process.graphTaskStarted') // 保存 task_id 用于轮询 const taskId = response.data.task_id @@ -697,12 +726,17 @@ const startBuildGraph = async () => { // 启动任务状态轮询 startPollingTask(taskId) } else { - error.value = response.error || '启动图谱构建失败' + error.value = response.error || t('process.startGraphBuildFailed') buildProgress.value = null } } catch (err) { console.error('Build graph error:', err) - error.value = '启动图谱构建失败: ' + (err.message || '未知错误') + error.value = `${t('process.startGraphBuildFailed')}: ${formatApiError({ + err, + t, + resolveBaseURL, + locationOrigin: window.location.origin + })}` buildProgress.value = null } } @@ -751,14 +785,14 @@ const fetchGraphData = async () => { if (graphResponse.success && graphResponse.data) { const newData = graphResponse.data - const newNodeCount = newData.node_count || newData.nodes?.length || 0 - const oldNodeCount = graphData.value?.node_count || graphData.value?.nodes?.length || 0 + const newSignature = buildGraphSignature(newData) - console.log('Fetching graph data, nodes:', newNodeCount, 'edges:', newData.edge_count || newData.edges?.length || 0) + console.log('Fetching graph data, nodes:', newData.node_count || newData.nodes?.length || 0, 'edges:', newData.edge_count || newData.edges?.length || 0) // 数据有变化时更新渲染 - if (newNodeCount !== oldNodeCount || !graphData.value) { + if (newSignature !== graphSignature.value || !graphData.value) { graphData.value = newData + graphSignature.value = newSignature await nextTick() renderGraph() } @@ -791,13 +825,13 @@ const pollTaskStatus = async (taskId) => { // 更新进度显示 buildProgress.value = { progress: task.progress || 0, - message: task.message || '处理中...' + message: task.message || t('process.defaultProgressMessage') } console.log('Task status:', task.status, 'Progress:', task.progress) if (task.status === 'completed') { - console.log('✅ 图谱构建完成,正在加载完整数据...') + console.log('Graph build completed, loading full data...') stopPolling() stopGraphPolling() @@ -806,7 +840,7 @@ const pollTaskStatus = async (taskId) => { // 更新进度显示为完成状态 buildProgress.value = { progress: 100, - message: '构建完成,正在加载图谱...' + message: t('process.buildCompletedLoadingGraph') } // 重新加载项目数据获取 graph_id @@ -827,7 +861,7 @@ const pollTaskStatus = async (taskId) => { } else if (task.status === 'failed') { stopPolling() stopGraphPolling() - error.value = '图谱构建失败: ' + (task.error || '未知错误') + error.value = `${t('process.startGraphBuildFailed')}: ${task.error || t('process.unknownError')}` buildProgress.value = null } } @@ -851,6 +885,7 @@ const loadGraph = async (graphId) => { if (response.success) { graphData.value = response.data + graphSignature.value = buildGraphSignature(response.data) await nextTick() renderGraph() } @@ -905,39 +940,17 @@ const renderGraph = () => { .attr('y', height / 2) .attr('text-anchor', 'middle') .attr('fill', '#999') - .text('等待图谱数据...') + .text(t('process.waitingGraphData')) return } - // 创建节点映射用于查找名称 - const nodeMap = {} - nodesData.forEach(n => { - nodeMap[n.uuid] = n + const { nodes, edges } = mapProcessGraphData({ + nodes: nodesData, + edges: edgesData, + unnamedNodeLabel: t('process.unnamedNode'), + unknownNodeLabel: t('process.unknownNode'), }) - const nodes = nodesData.map(n => ({ - id: n.uuid, - name: n.name || '未命名', - type: n.labels?.find(l => l !== 'Entity' && l !== 'Node') || 'Entity', - rawData: n // 保存原始数据 - })) - - // 创建节点ID集合用于过滤有效边 - const nodeIds = new Set(nodes.map(n => n.id)) - - const edges = edgesData - .filter(e => nodeIds.has(e.source_node_uuid) && nodeIds.has(e.target_node_uuid)) - .map(e => ({ - source: e.source_node_uuid, - target: e.target_node_uuid, - type: e.fact_type || e.name || 'RELATED_TO', - rawData: { - ...e, - source_name: nodeMap[e.source_node_uuid]?.name || '未知', - target_name: nodeMap[e.target_node_uuid]?.name || '未知' - } - })) - console.log('Nodes:', nodes.length, 'Edges:', edges.length) // 颜色映射 @@ -2065,4 +2078,4 @@ onUnmounted(() => { display: none; } } -</style> \ No newline at end of file +</style> diff --git a/frontend/src/views/ReportView.vue b/frontend/src/views/ReportView.vue index 84a3e2a3..c7723b1a 100644 --- a/frontend/src/views/ReportView.vue +++ b/frontend/src/views/ReportView.vue @@ -15,15 +15,16 @@ :class="{ active: viewMode === mode }" @click="viewMode = mode" > - {{ { graph: '图谱', split: '双栏', workbench: '工作台' }[mode] }} + {{ { graph: t('mainView.graph'), split: t('mainView.split'), workbench: t('mainView.workbench') }[mode] }} </button> </div> </div> <div class="header-right"> + <LanguageSelector light /> <div class="workflow-step"> <span class="step-num">Step 4/5</span> - <span class="step-name">报告生成</span> + <span class="step-name">{{ t('reportView.stepName') }}</span> </div> <div class="step-divider"></div> <span class="status-indicator" :class="statusClass"> @@ -63,15 +64,18 @@ <script setup> import { ref, computed, onMounted, watch } from 'vue' +import { useI18n } from 'vue-i18n' import { useRoute, useRouter } from 'vue-router' import GraphPanel from '../components/GraphPanel.vue' import Step4Report from '../components/Step4Report.vue' +import LanguageSelector from '../components/LanguageSelector.vue' import { getProject, getGraphData } from '../api/graph' import { getSimulation } from '../api/simulation' import { getReport } from '../api/report' const route = useRoute() const router = useRouter() +const { t } = useI18n() // Props const props = defineProps({ @@ -109,9 +113,9 @@ const statusClass = computed(() => { }) const statusText = computed(() => { - if (currentStatus.value === 'error') return 'Error' - if (currentStatus.value === 'completed') return 'Completed' - return 'Generating' + if (currentStatus.value === 'error') return t('reportView.statusError') + if (currentStatus.value === 'completed') return t('reportView.statusCompleted') + return t('reportView.statusGenerating') }) // --- Helpers --- @@ -139,7 +143,7 @@ const toggleMaximize = (target) => { // --- Data Logic --- const loadReportData = async () => { try { - addLog(`加载报告数据: ${currentReportId.value}`) + addLog(t('reportView.loadingReportData', { id: currentReportId.value })) // 获取 report 信息以获取 simulation_id const reportRes = await getReport(currentReportId.value) @@ -158,7 +162,7 @@ const loadReportData = async () => { const projRes = await getProject(simData.project_id) if (projRes.success && projRes.data) { projectData.value = projRes.data - addLog(`项目加载成功: ${projRes.data.project_id}`) + addLog(t('reportView.projectLoaded', { id: projRes.data.project_id })) // 获取 graph 数据 if (projRes.data.graph_id) { @@ -169,10 +173,10 @@ const loadReportData = async () => { } } } else { - addLog(`获取报告信息失败: ${reportRes.error || '未知错误'}`) + addLog(t('reportView.reportInfoFailed', { message: reportRes.error || t('process.unknownError') })) } } catch (err) { - addLog(`加载异常: ${err.message}`) + addLog(t('reportView.loadException', { message: err.message })) } } @@ -183,10 +187,10 @@ const loadGraph = async (graphId) => { const res = await getGraphData(graphId) if (res.success) { graphData.value = res.data - addLog('图谱数据加载成功') + addLog(t('reportView.graphLoaded')) } } catch (err) { - addLog(`图谱加载失败: ${err.message}`) + addLog(t('reportView.graphLoadFailed', { message: err.message })) } finally { graphLoading.value = false } @@ -207,7 +211,7 @@ watch(() => route.params.reportId, (newId) => { }, { immediate: true }) onMounted(() => { - addLog('ReportView 初始化') + addLog(t('reportView.initLog')) loadReportData() }) </script> diff --git a/frontend/src/views/SimulationRunView.vue b/frontend/src/views/SimulationRunView.vue index 14ebc5f9..3788fe10 100644 --- a/frontend/src/views/SimulationRunView.vue +++ b/frontend/src/views/SimulationRunView.vue @@ -8,22 +8,23 @@ <div class="header-center"> <div class="view-switcher"> - <button - v-for="mode in ['graph', 'split', 'workbench']" + <button + v-for="mode in ['graph', 'split', 'workbench']" :key="mode" class="switch-btn" :class="{ active: viewMode === mode }" @click="viewMode = mode" > - {{ { graph: '图谱', split: '双栏', workbench: '工作台' }[mode] }} + {{ { graph: t('mainView.graph'), split: t('mainView.split'), workbench: t('mainView.workbench') }[mode] }} </button> </div> </div> <div class="header-right"> + <LanguageSelector light /> <div class="workflow-step"> <span class="step-num">Step 3/5</span> - <span class="step-name">开始模拟</span> + <span class="step-name">{{ t('mainView.stepSim') }}</span> </div> <div class="step-divider"></div> <span class="status-indicator" :class="statusClass"> @@ -53,6 +54,7 @@ :simulationId="currentSimulationId" :maxRounds="maxRounds" :minutesPerRound="minutesPerRound" + :replayOnly="replayOnly" :projectData="projectData" :graphData="graphData" :systemLogs="systemLogs" @@ -69,13 +71,16 @@ <script setup> import { ref, computed, onMounted, onUnmounted, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' +import { useI18n } from 'vue-i18n' import GraphPanel from '../components/GraphPanel.vue' import Step3Simulation from '../components/Step3Simulation.vue' +import LanguageSelector from '../components/LanguageSelector.vue' import { getProject, getGraphData } from '../api/graph' import { getSimulation, getSimulationConfig, stopSimulation, closeSimulationEnv, getEnvStatus } from '../api/simulation' const route = useRoute() const router = useRouter() +const { t } = useI18n() // Props const props = defineProps({ @@ -87,6 +92,7 @@ const viewMode = ref('split') // Data State const currentSimulationId = ref(route.params.simulationId) +const replayOnly = computed(() => route.query.replay === '1' || route.query.replay === 'true') // 直接在初始化时从 query 参数获取 maxRounds,确保子组件能立即获取到值 const maxRounds = ref(route.query.maxRounds ? parseInt(route.query.maxRounds) : null) const minutesPerRound = ref(30) // 默认每轮30分钟 @@ -115,9 +121,9 @@ const statusClass = computed(() => { }) const statusText = computed(() => { - if (currentStatus.value === 'error') return 'Error' - if (currentStatus.value === 'completed') return 'Completed' - return 'Running' + if (currentStatus.value === 'error') return t('common.error') + if (currentStatus.value === 'completed') return t('common.completed') + return t('simulationRunView.running') }) const isSimulating = computed(() => currentStatus.value === 'processing') @@ -146,7 +152,7 @@ const toggleMaximize = (target) => { const handleGoBack = async () => { // 在返回 Step 2 之前,先关闭正在运行的模拟 - addLog('准备返回 Step 2,正在关闭模拟...') + addLog(t('simulationRunView.logs.preparingGoBack')) // 停止轮询 stopGraphRefresh() @@ -156,36 +162,36 @@ const handleGoBack = async () => { const envStatusRes = await getEnvStatus({ simulation_id: currentSimulationId.value }) if (envStatusRes.success && envStatusRes.data?.env_alive) { - addLog('正在关闭模拟环境...') + addLog(t('simulationRunView.logs.closingEnv')) try { await closeSimulationEnv({ simulation_id: currentSimulationId.value, timeout: 10 }) - addLog('✓ 模拟环境已关闭') + addLog(t('simulationRunView.logs.envClosed')) } catch (closeErr) { - addLog(`关闭模拟环境失败,尝试强制停止...`) + addLog(t('simulationRunView.logs.closeEnvFailedTryingForce')) try { await stopSimulation({ simulation_id: currentSimulationId.value }) - addLog('✓ 模拟已强制停止') + addLog(t('simulationRunView.logs.forceStopped')) } catch (stopErr) { - addLog(`强制停止失败: ${stopErr.message}`) + addLog(t('simulationRunView.logs.forceStopFailed', { message: stopErr.message })) } } } else { // 环境未运行,检查是否需要停止进程 if (isSimulating.value) { - addLog('正在停止模拟进程...') + addLog(t('simulationRunView.logs.stoppingProcess')) try { await stopSimulation({ simulation_id: currentSimulationId.value }) - addLog('✓ 模拟已停止') + addLog(t('simulationRunView.logs.simStopped')) } catch (err) { - addLog(`停止模拟失败: ${err.message}`) + addLog(t('simulationRunView.logs.stopFailed', { message: err.message })) } } } } catch (err) { - addLog(`检查模拟状态失败: ${err.message}`) + addLog(t('simulationRunView.logs.checkStatusFailed', { message: err.message })) } // 返回到 Step 2 (环境搭建) @@ -195,13 +201,13 @@ const handleGoBack = async () => { const handleNextStep = () => { // Step3Simulation 组件会直接处理报告生成和路由跳转 // 这个方法仅作为备用 - addLog('进入 Step 4: 报告生成') + addLog(t('simulationRunView.logs.enterStep4')) } // --- Data Logic --- const loadSimulationData = async () => { try { - addLog(`加载模拟数据: ${currentSimulationId.value}`) + addLog(t('simulationRunView.logs.loadingSimulation', { id: currentSimulationId.value })) // 获取 simulation 信息 const simRes = await getSimulation(currentSimulationId.value) @@ -213,10 +219,10 @@ const loadSimulationData = async () => { const configRes = await getSimulationConfig(currentSimulationId.value) if (configRes.success && configRes.data?.time_config?.minutes_per_round) { minutesPerRound.value = configRes.data.time_config.minutes_per_round - addLog(`时间配置: 每轮 ${minutesPerRound.value} 分钟`) + addLog(t('simulationRunView.logs.timeConfig', { count: minutesPerRound.value })) } } catch (configErr) { - addLog(`获取时间配置失败,使用默认值: ${minutesPerRound.value}分钟/轮`) + addLog(t('simulationRunView.logs.timeConfigFallback', { count: minutesPerRound.value })) } // 获取 project 信息 @@ -224,7 +230,7 @@ const loadSimulationData = async () => { const projRes = await getProject(simData.project_id) if (projRes.success && projRes.data) { projectData.value = projRes.data - addLog(`项目加载成功: ${projRes.data.project_id}`) + addLog(t('simulationRunView.logs.projectLoaded', { id: projRes.data.project_id })) // 获取 graph 数据 if (projRes.data.graph_id) { @@ -233,10 +239,10 @@ const loadSimulationData = async () => { } } } else { - addLog(`加载模拟数据失败: ${simRes.error || '未知错误'}`) + addLog(t('simulationRunView.logs.loadSimulationFailed', { message: simRes.error || t('process.unknownError') })) } } catch (err) { - addLog(`加载异常: ${err.message}`) + addLog(t('simulationRunView.logs.loadException', { message: err.message })) } } @@ -252,11 +258,11 @@ const loadGraph = async (graphId) => { if (res.success) { graphData.value = res.data if (!isSimulating.value) { - addLog('图谱数据加载成功') + addLog(t('simulationRunView.logs.graphLoaded')) } } } catch (err) { - addLog(`图谱加载失败: ${err.message}`) + addLog(t('simulationRunView.logs.graphLoadFailed', { message: err.message })) } finally { graphLoading.value = false } @@ -273,7 +279,7 @@ let graphRefreshTimer = null const startGraphRefresh = () => { if (graphRefreshTimer) return - addLog('开启图谱实时刷新 (30s)') + addLog(t('simulationRunView.logs.graphRefreshStarted')) // 立即刷新一次,然后每30秒刷新 graphRefreshTimer = setInterval(refreshGraph, 30000) } @@ -282,7 +288,7 @@ const stopGraphRefresh = () => { if (graphRefreshTimer) { clearInterval(graphRefreshTimer) graphRefreshTimer = null - addLog('停止图谱实时刷新') + addLog(t('simulationRunView.logs.graphRefreshStopped')) } } @@ -295,11 +301,11 @@ watch(isSimulating, (newValue) => { }, { immediate: true }) onMounted(() => { - addLog('SimulationRunView 初始化') + addLog(t('simulationRunView.logs.init')) // 记录 maxRounds 配置(值已在初始化时从 query 参数获取) if (maxRounds.value) { - addLog(`自定义模拟轮数: ${maxRounds.value}`) + addLog(t('simulationRunView.logs.customRounds', { count: maxRounds.value })) } loadSimulationData() @@ -444,4 +450,3 @@ onUnmounted(() => { border-right: 1px solid #EAEAEA; } </style> - diff --git a/frontend/src/views/SimulationView.vue b/frontend/src/views/SimulationView.vue index 4b44b397..8a393d2b 100644 --- a/frontend/src/views/SimulationView.vue +++ b/frontend/src/views/SimulationView.vue @@ -8,22 +8,23 @@ <div class="header-center"> <div class="view-switcher"> - <button - v-for="mode in ['graph', 'split', 'workbench']" + <button + v-for="mode in ['graph', 'split', 'workbench']" :key="mode" class="switch-btn" :class="{ active: viewMode === mode }" @click="viewMode = mode" > - {{ { graph: '图谱', split: '双栏', workbench: '工作台' }[mode] }} + {{ { graph: t('mainView.graph'), split: t('mainView.split'), workbench: t('mainView.workbench') }[mode] }} </button> </div> </div> <div class="header-right"> + <LanguageSelector light /> <div class="workflow-step"> <span class="step-num">Step 2/5</span> - <span class="step-name">环境搭建</span> + <span class="step-name">{{ t('mainView.stepEnv') }}</span> </div> <div class="step-divider"></div> <span class="status-indicator" :class="statusClass"> @@ -50,6 +51,7 @@ <div class="panel-wrapper right" :style="rightPanelStyle"> <Step2EnvSetup :simulationId="currentSimulationId" + :simulationData="simulationData" :projectData="projectData" :graphData="graphData" :systemLogs="systemLogs" @@ -66,13 +68,16 @@ <script setup> import { ref, computed, onMounted, onUnmounted } from 'vue' import { useRoute, useRouter } from 'vue-router' +import { useI18n } from 'vue-i18n' import GraphPanel from '../components/GraphPanel.vue' import Step2EnvSetup from '../components/Step2EnvSetup.vue' +import LanguageSelector from '../components/LanguageSelector.vue' import { getProject, getGraphData } from '../api/graph' import { getSimulation, stopSimulation, getEnvStatus, closeSimulationEnv } from '../api/simulation' const route = useRoute() const router = useRouter() +const { t } = useI18n() // Props const props = defineProps({ @@ -84,6 +89,7 @@ const viewMode = ref('split') // Data State const currentSimulationId = ref(route.params.simulationId) +const simulationData = ref(null) const projectData = ref(null) const graphData = ref(null) const graphLoading = ref(false) @@ -109,9 +115,9 @@ const statusClass = computed(() => { }) const statusText = computed(() => { - if (currentStatus.value === 'error') return 'Error' - if (currentStatus.value === 'completed') return 'Ready' - return 'Preparing' + if (currentStatus.value === 'error') return t('common.error') + if (currentStatus.value === 'completed') return t('mainView.statusReady') + return t('simulationView.preparing') }) // --- Helpers --- @@ -146,13 +152,13 @@ const handleGoBack = () => { } const handleNextStep = (params = {}) => { - addLog('进入 Step 3: 开始模拟') + addLog(t('simulationView.logs.enterStep3')) // 记录模拟轮数配置 if (params.maxRounds) { - addLog(`自定义模拟轮数: ${params.maxRounds} 轮`) + addLog(t('simulationView.logs.customRounds', { count: params.maxRounds })) } else { - addLog('使用自动配置的模拟轮数') + addLog(t('simulationView.logs.autoRounds')) } // 构建路由参数 @@ -184,7 +190,7 @@ const checkAndStopRunningSimulation = async () => { const envStatusRes = await getEnvStatus({ simulation_id: currentSimulationId.value }) if (envStatusRes.success && envStatusRes.data?.env_alive) { - addLog('检测到模拟环境正在运行,正在关闭...') + addLog(t('simulationView.logs.envRunningClosing')) // 尝试优雅关闭模拟环境 try { @@ -194,14 +200,14 @@ const checkAndStopRunningSimulation = async () => { }) if (closeRes.success) { - addLog('✓ 模拟环境已关闭') + addLog(t('simulationView.logs.envClosed')) } else { - addLog(`关闭模拟环境失败: ${closeRes.error || '未知错误'}`) + addLog(t('simulationView.logs.closeEnvFailed', { message: closeRes.error || t('process.unknownError') })) // 如果优雅关闭失败,尝试强制停止 await forceStopSimulation() } } catch (closeErr) { - addLog(`关闭模拟环境异常: ${closeErr.message}`) + addLog(t('simulationView.logs.closeEnvException', { message: closeErr.message })) // 如果优雅关闭异常,尝试强制停止 await forceStopSimulation() } @@ -209,7 +215,7 @@ const checkAndStopRunningSimulation = async () => { // 环境未运行,但可能进程还在,检查模拟状态 const simRes = await getSimulation(currentSimulationId.value) if (simRes.success && simRes.data?.status === 'running') { - addLog('检测到模拟状态为运行中,正在停止...') + addLog(t('simulationView.logs.simRunningStopping')) await forceStopSimulation() } } @@ -226,30 +232,31 @@ const forceStopSimulation = async () => { try { const stopRes = await stopSimulation({ simulation_id: currentSimulationId.value }) if (stopRes.success) { - addLog('✓ 模拟已强制停止') + addLog(t('simulationView.logs.forceStopped')) } else { - addLog(`强制停止模拟失败: ${stopRes.error || '未知错误'}`) + addLog(t('simulationView.logs.forceStopFailed', { message: stopRes.error || t('process.unknownError') })) } } catch (err) { - addLog(`强制停止模拟异常: ${err.message}`) + addLog(t('simulationView.logs.forceStopException', { message: err.message })) } } const loadSimulationData = async () => { try { - addLog(`加载模拟数据: ${currentSimulationId.value}`) + addLog(t('simulationView.logs.loadingSimulation', { id: currentSimulationId.value })) // 获取 simulation 信息 const simRes = await getSimulation(currentSimulationId.value) if (simRes.success && simRes.data) { const simData = simRes.data + simulationData.value = simData // 获取 project 信息 if (simData.project_id) { const projRes = await getProject(simData.project_id) if (projRes.success && projRes.data) { projectData.value = projRes.data - addLog(`项目加载成功: ${projRes.data.project_id}`) + addLog(t('simulationView.logs.projectLoaded', { id: projRes.data.project_id })) // 获取 graph 数据 if (projRes.data.graph_id) { @@ -258,10 +265,10 @@ const loadSimulationData = async () => { } } } else { - addLog(`加载模拟数据失败: ${simRes.error || '未知错误'}`) + addLog(t('simulationView.logs.loadSimulationFailed', { message: simRes.error || t('process.unknownError') })) } } catch (err) { - addLog(`加载异常: ${err.message}`) + addLog(t('simulationView.logs.loadException', { message: err.message })) } } @@ -271,10 +278,10 @@ const loadGraph = async (graphId) => { const res = await getGraphData(graphId) if (res.success) { graphData.value = res.data - addLog('图谱数据加载成功') + addLog(t('simulationView.logs.graphLoaded')) } } catch (err) { - addLog(`图谱加载失败: ${err.message}`) + addLog(t('simulationView.logs.graphLoadFailed', { message: err.message })) } finally { graphLoading.value = false } @@ -287,7 +294,7 @@ const refreshGraph = () => { } onMounted(async () => { - addLog('SimulationView 初始化') + addLog(t('simulationView.logs.init')) // 检查并关闭正在运行的模拟(用户从 Step 3 返回时) await checkAndStopRunningSimulation() @@ -431,4 +438,3 @@ onMounted(async () => { border-right: 1px solid #EAEAEA; } </style> - diff --git a/frontend/src/views/mainViewLogMessages.js b/frontend/src/views/mainViewLogMessages.js new file mode 100644 index 00000000..2ac85865 --- /dev/null +++ b/frontend/src/views/mainViewLogMessages.js @@ -0,0 +1,17 @@ +export function formatMainViewStepLog(action, stepNumber, stepName, t) { + const key = action === 'back' + ? 'mainView.logs.returnStep' + : 'mainView.logs.enterStep' + + return t(key, { + step: stepNumber, + name: stepName, + }) +} + +export function formatMainViewGraphRefreshLog(nodeCount, edgeCount, t) { + return t('mainView.logs.graphDataRefreshed', { + nodeCount: nodeCount || 0, + edgeCount: edgeCount || 0, + }) +} diff --git a/frontend/src/views/processGraphData.js b/frontend/src/views/processGraphData.js new file mode 100644 index 00000000..bb67ccf5 --- /dev/null +++ b/frontend/src/views/processGraphData.js @@ -0,0 +1,317 @@ +const NON_WORD_RE = /[^\p{L}\p{N}]+/gu +const PERSON_PREFIXES = [ + '美国总统', + '总统', + 'president', + 'formerpresident', + 'currentpresident', + 'ceo', + 'founder', + 'cofounder', + 'professor', + 'doctor', + 'dr', + 'mr', + 'mrs', + 'ms', + 'sir', +] +const ORG_SUFFIXES = [ + '有限责任公司', + '股份有限公司', + '有限公司', + '集团', + '公司', + 'corporation', + 'corp', + 'inc', + 'ltd', + 'llc', + 'university', +] +const PERSON_TYPE_HINTS = [ + 'person', + 'student', + 'alumni', + 'player', + 'leader', + 'figure', + 'expert', + 'human', + '人物', + '学生', + '校友', + '个人', + '公众人物', +] +const ORG_TYPE_HINTS = [ + 'organization', + 'company', + 'institution', + 'agency', + 'university', + 'media', + 'group', + '企业', + '机构', + '组织', +] + +const getNodeType = (node) => + node.labels?.find((label) => label !== 'Entity' && label !== 'Node') || 'Entity' + +const normalizeEntityName = (name) => + (name || '').normalize('NFKC').trim().toLowerCase().replace(NON_WORD_RE, '') + +const stripKnownAffixes = (normalizedName, entityType) => { + const normalizedType = (entityType || '').trim().toLowerCase() + let stripped = normalizedName + + if (PERSON_TYPE_HINTS.some((hint) => normalizedType.includes(hint))) { + for (const prefix of PERSON_PREFIXES) { + if (stripped.startsWith(prefix) && stripped.length > prefix.length + 1) { + stripped = stripped.slice(prefix.length) + break + } + } + } + + if (ORG_TYPE_HINTS.some((hint) => normalizedType.includes(hint))) { + for (const suffix of ORG_SUFFIXES) { + if (stripped.endsWith(suffix) && stripped.length > suffix.length + 1) { + stripped = stripped.slice(0, -suffix.length) + break + } + } + } + + return stripped || normalizedName +} + +const getAliasKey = (node) => { + const normalizedName = normalizeEntityName(node.name) + if (!normalizedName) { + return '' + } + return stripKnownAffixes(normalizedName, getNodeType(node)) +} + +const areDuplicateNodes = (left, right) => { + const leftType = getNodeType(left) + const rightType = getNodeType(right) + if (!leftType || leftType !== rightType) { + return false + } + + const leftName = normalizeEntityName(left.name) + const rightName = normalizeEntityName(right.name) + if (!leftName || !rightName) { + return false + } + + if (leftName === rightName) { + return true + } + + const leftKey = getAliasKey(left) + const rightKey = getAliasKey(right) + if (!leftKey || leftKey !== rightKey || leftKey.length < 2) { + return false + } + + const [shorter, longer] = [leftName, rightName].sort((a, b) => a.length - b.length) + return shorter.length >= 2 && longer.includes(shorter) +} + +const getNodeScore = (node) => { + const attributeCount = Object.keys(node.attributes || {}).length + const summaryLength = (node.summary || '').length + const labelCount = (node.labels || []).length + return attributeCount * 10 + summaryLength + labelCount +} + +const pickPrimaryNode = (left, right) => { + const leftName = normalizeEntityName(left.name) + const rightName = normalizeEntityName(right.name) + + if (leftName && rightName && leftName.length !== rightName.length) { + return leftName.length < rightName.length ? left : right + } + + return getNodeScore(left) >= getNodeScore(right) ? left : right +} + +const uniqueValues = (values) => [...new Set(values.filter(Boolean))] + +const mergeDuplicateNodes = (nodes) => { + const mergedNodes = [] + + nodes.forEach((node) => { + const preparedNode = { + ...node, + labels: [...(node.labels || [])], + ...(node.attributes ? { attributes: { ...node.attributes } } : {}), + __alias_names: uniqueValues([...(node.alias_names || []), node.name]), + __merged_node_uuids: uniqueValues([...(node.merged_node_uuids || []), node.uuid]), + } + + const duplicateIndex = mergedNodes.findIndex((existing) => areDuplicateNodes(existing, preparedNode)) + if (duplicateIndex === -1) { + mergedNodes.push(preparedNode) + return + } + + const existing = mergedNodes[duplicateIndex] + const primary = pickPrimaryNode(existing, preparedNode) + const secondary = primary === existing ? preparedNode : existing + + mergedNodes[duplicateIndex] = { + ...secondary, + ...primary, + labels: uniqueValues([...(primary.labels || []), ...(secondary.labels || [])]), + ...((primary.attributes || secondary.attributes) + ? { + attributes: { + ...(secondary.attributes || {}), + ...(primary.attributes || {}), + }, + } + : {}), + __alias_names: uniqueValues([ + ...(primary.__alias_names || []), + ...(secondary.__alias_names || []), + primary.name, + secondary.name, + ]), + __merged_node_uuids: uniqueValues([ + ...(primary.__merged_node_uuids || []), + ...(secondary.__merged_node_uuids || []), + ]), + } + }) + + const nodeIdRemap = {} + const sanitizedNodes = mergedNodes.map((node) => { + ;(node.__merged_node_uuids || []).forEach((uuid) => { + nodeIdRemap[uuid] = node.uuid + }) + + const sanitizedNode = { + ...node, + } + delete sanitizedNode.__alias_names + delete sanitizedNode.__merged_node_uuids + + if ((node.__alias_names || []).length > 1) { + sanitizedNode.alias_names = [...node.__alias_names] + } else { + delete sanitizedNode.alias_names + } + + if ((node.__merged_node_uuids || []).length > 1) { + sanitizedNode.merged_node_uuids = [...node.__merged_node_uuids] + } else { + delete sanitizedNode.merged_node_uuids + } + + return sanitizedNode + }) + + return { mergedNodes: sanitizedNodes, nodeIdRemap } +} + +export const mapProcessGraphData = ({ + nodes = [], + edges = [], + unnamedNodeLabel, + unknownNodeLabel, +}) => { + const { mergedNodes, nodeIdRemap } = mergeDuplicateNodes(nodes) + const nodeMap = {} + mergedNodes.forEach((node) => { + nodeMap[node.uuid] = node + }) + + const mappedNodes = mergedNodes.map((node) => ({ + id: node.uuid, + name: node.name || unnamedNodeLabel, + type: getNodeType(node), + rawData: node, + })) + + const nodeIds = new Set(mappedNodes.map((node) => node.id)) + const seenEdgeKeys = new Set() + const mappedEdges = edges + .map((edge) => { + const source = nodeIdRemap[edge.source_node_uuid] || edge.source_node_uuid + const target = nodeIdRemap[edge.target_node_uuid] || edge.target_node_uuid + const type = edge.fact_type || edge.name || 'RELATED_TO' + return { + source, + target, + type, + rawData: { + ...edge, + source_node_uuid: source, + target_node_uuid: target, + source_name: nodeMap[source]?.name || unknownNodeLabel, + target_name: nodeMap[target]?.name || unknownNodeLabel, + }, + } + }) + .filter((edge) => nodeIds.has(edge.source) && nodeIds.has(edge.target)) + .filter((edge) => { + const key = `${edge.source}::${edge.target}::${edge.type}` + if (seenEdgeKeys.has(key)) { + return false + } + seenEdgeKeys.add(key) + return true + }) + + return { + nodes: mappedNodes, + edges: mappedEdges, + } +} + +export const getProcessGraphSignature = ({ + nodes = [], + edges = [], + unnamedNodeLabel = 'Unnamed', + unknownNodeLabel = 'Unknown', +}) => { + const mapped = mapProcessGraphData({ + nodes, + edges, + unnamedNodeLabel, + unknownNodeLabel, + }) + + const nodeSignature = mapped.nodes + .map((node) => ({ + id: node.id, + name: node.name, + type: node.type, + aliases: [...(node.rawData?.alias_names || [])].sort(), + merged: [...(node.rawData?.merged_node_uuids || [])].sort(), + })) + .sort((left, right) => left.id.localeCompare(right.id)) + + const edgeSignature = mapped.edges + .map((edge) => ({ + source: edge.source, + target: edge.target, + type: edge.type, + })) + .sort((left, right) => ( + `${left.source}::${left.target}::${left.type}`.localeCompare( + `${right.source}::${right.target}::${right.type}`, + ) + )) + + return JSON.stringify({ + nodes: nodeSignature, + edges: edgeSignature, + }) +} diff --git a/frontend/tests/apiConfigDiagnostics.test.mjs b/frontend/tests/apiConfigDiagnostics.test.mjs new file mode 100644 index 00000000..cd4b297c --- /dev/null +++ b/frontend/tests/apiConfigDiagnostics.test.mjs @@ -0,0 +1,245 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { buildBackendDiagnosticModel } from '../src/components/apiConfigDiagnostics.js' + +const t = (key, params = {}) => { + const messages = { + 'common.none': 'None', + 'apiConfig.diagnostics.configured': 'Backend config detected', + 'apiConfig.diagnostics.configuredOpenAI': 'Direct OPENAI/Codex-compatible path detected', + 'apiConfig.diagnostics.baseUrlConflictTitle': 'Conflicting backend base URLs detected', + 'apiConfig.diagnostics.zepMissingNote': 'The direct LLM path is configured, but Step 1 graph build and graph-backed report tools still require ZEP_API_KEY until a non-Zep backend is landed.', + 'apiConfig.diagnostics.nextStepsTitle': 'Next usable path', + 'apiConfig.diagnostics.nextStepOpenStep2': 'Open Step 2 to generate the simulation environment, then continue into Step 3 with the direct backend.', + 'apiConfig.diagnostics.nextStepReuseStep5': 'After Step 2/3 has produced a simulation environment, Step 5 can still be used for role interaction even without a Step 4 report.', + 'apiConfig.diagnostics.nextStepWaitForNonZep': 'Step 1 graph build and Step 4 graph-backed report tools remain blocked until ZEP_API_KEY is configured or a non-Zep graph backend is added.', + 'apiConfig.diagnostics.incomplete': 'Backend config needs attention', + 'apiConfig.diagnostics.modeLabel': 'Backend mode', + 'apiConfig.diagnostics.sourceLabel': 'Resolved config source', + 'apiConfig.diagnostics.envLabel': 'Resolved env vars', + 'apiConfig.diagnostics.baseUrlLabel': 'Backend LLM base URL', + 'apiConfig.diagnostics.modelLabel': 'Backend model', + 'apiConfig.diagnostics.modeOpenAICompatible': 'OpenAI-compatible', + 'apiConfig.diagnostics.sourceOpenAIAliases': 'Direct OPENAI_* aliases', + 'apiConfig.diagnostics.sourceMixedAliases': 'Mixed OPENAI_* and LLM_* aliases', + 'apiConfig.diagnostics.sourceProjectAliases': 'Project LLM_* aliases', + 'apiConfig.diagnostics.sourceUnknown': 'Not resolved', + 'apiConfig.diagnostics.baseUrlConflictNote': '{configuredEnvNames} are set to different values. MiroFish is currently using {selectedEnv}={selectedValue}.', + 'apiConfig.diagnostics.directLlmLabel': 'Direct LLM usage', + 'apiConfig.diagnostics.graphBuildLabel': 'Step 1 graph build', + 'apiConfig.diagnostics.reportToolsLabel': 'Step 4 graph-backed report tools', + 'apiConfig.diagnostics.step5Label': 'Step 5 on existing simulation', + 'apiConfig.diagnostics.capabilityReady': 'Ready', + 'apiConfig.diagnostics.capabilityNeedsZep': 'Needs ZEP_API_KEY', + 'apiConfig.diagnostics.capabilityReadyExistingSimulation': 'Ready when an existing simulation environment is available', + 'apiConfig.diagnostics.capabilityNeedsExistingSimulation': 'Needs an existing simulation environment', + 'apiConfig.diagnostics.capabilityNeedsBackendConfig': 'Needs backend config', + } + + let message = messages[key] + for (const [paramKey, value] of Object.entries(params)) { + message = message.replace(`{${paramKey}}`, String(value)) + } + return message +} + +test('buildBackendDiagnosticModel highlights direct OPENAI alias resolution', () => { + const diagnostic = buildBackendDiagnosticModel({ + summary: { + llm: { + configured: true, + backend_mode: 'openai_compatible', + base_url: 'https://api.openai.com/v1', + model: 'gpt-4.1-mini', + sources: { + api_key_env: 'OPENAI_API_KEY', + base_url_env: 'OPENAI_API_BASE_URL', + model_env: 'OPENAI_MODEL', + base_url_conflict: null, + uses_openai_aliases: true, + uses_project_aliases: false, + }, + }, + capabilities: { + direct_llm: { ready: true }, + graph_build: { ready: true, requires_zep: true }, + graph_report_tools: { ready: true, requires_zep: true }, + existing_simulation_interaction: { ready: true, requires_existing_simulation: true }, + }, + }, + validation: { + is_valid: true, + }, + }, t) + + assert.equal(diagnostic.tone, 'ready') + assert.equal(diagnostic.headline, 'Direct OPENAI/Codex-compatible path detected') + assert.equal(diagnostic.note, '') + assert.deepEqual(diagnostic.nextSteps, []) + assert.deepEqual(diagnostic.rows, [ + { label: 'Backend mode', value: 'OpenAI-compatible' }, + { label: 'Resolved config source', value: 'Direct OPENAI_* aliases' }, + { label: 'Resolved env vars', value: 'OPENAI_API_KEY / OPENAI_API_BASE_URL / OPENAI_MODEL' }, + { label: 'Backend LLM base URL', value: 'https://api.openai.com/v1' }, + { label: 'Backend model', value: 'gpt-4.1-mini' }, + { label: 'Direct LLM usage', value: 'Ready' }, + { label: 'Step 1 graph build', value: 'Ready' }, + { label: 'Step 4 graph-backed report tools', value: 'Ready' }, + { label: 'Step 5 on existing simulation', value: 'Ready when an existing simulation environment is available' }, + ]) +}) + +test('buildBackendDiagnosticModel falls back cleanly for project aliases and missing values', () => { + const diagnostic = buildBackendDiagnosticModel({ + summary: { + llm: { + configured: false, + sources: { + uses_project_aliases: true, + uses_openai_aliases: false, + }, + }, + }, + validation: { + is_valid: false, + }, + }, t) + + assert.equal(diagnostic.tone, 'warning') + assert.equal(diagnostic.headline, 'Backend config needs attention') + assert.deepEqual(diagnostic.nextSteps, []) + assert.deepEqual(diagnostic.rows, [ + { label: 'Backend mode', value: 'None' }, + { label: 'Resolved config source', value: 'Project LLM_* aliases' }, + { label: 'Resolved env vars', value: 'None' }, + { label: 'Backend LLM base URL', value: 'None' }, + { label: 'Backend model', value: 'None' }, + { label: 'Direct LLM usage', value: 'None' }, + { label: 'Step 1 graph build', value: 'None' }, + { label: 'Step 4 graph-backed report tools', value: 'None' }, + { label: 'Step 5 on existing simulation', value: 'None' }, + ]) +}) + +test('buildBackendDiagnosticModel flags mixed alias resolution explicitly', () => { + const diagnostic = buildBackendDiagnosticModel({ + summary: { + llm: { + configured: true, + backend_mode: 'openai_compatible', + base_url: 'https://proxy.example/v1', + model: 'gpt-4.1-mini', + sources: { + api_key_env: 'OPENAI_API_KEY', + base_url_env: 'LLM_BASE_URL', + model_env: 'OPENAI_MODEL', + base_url_conflict: null, + uses_openai_aliases: true, + uses_project_aliases: true, + }, + }, + }, + validation: { + is_valid: true, + }, + }, t) + + assert.equal(diagnostic.headline, 'Direct OPENAI/Codex-compatible path detected') + assert.equal(diagnostic.rows[1].value, 'Mixed OPENAI_* and LLM_* aliases') + assert.equal( + diagnostic.rows[2].value, + 'OPENAI_API_KEY / LLM_BASE_URL / OPENAI_MODEL', + ) + assert.equal(diagnostic.note, '') + assert.deepEqual(diagnostic.nextSteps, []) +}) + +test('buildBackendDiagnosticModel keeps the LLM path ready when only ZEP is missing', () => { + const diagnostic = buildBackendDiagnosticModel({ + summary: { + llm: { + configured: true, + backend_mode: 'openai_compatible', + base_url: 'https://codex.example.test/v1', + model: 'gpt-4.1-mini', + sources: { + api_key_env: 'OPENAI_API_KEY', + base_url_env: 'OPENAI_API_BASE_URL', + model_env: 'OPENAI_MODEL', + base_url_conflict: null, + uses_openai_aliases: true, + uses_project_aliases: false, + }, + }, + capabilities: { + direct_llm: { ready: true }, + graph_build: { ready: false, requires_zep: true }, + graph_report_tools: { ready: false, requires_zep: true }, + existing_simulation_interaction: { ready: true, requires_existing_simulation: true }, + }, + }, + validation: { + is_valid: false, + errors: ['ZEP_API_KEY is not configured'], + }, + }, t) + + assert.equal(diagnostic.tone, 'ready') + assert.equal(diagnostic.headline, 'Direct OPENAI/Codex-compatible path detected') + assert.equal( + diagnostic.note, + 'The direct LLM path is configured, but Step 1 graph build and graph-backed report tools still require ZEP_API_KEY until a non-Zep backend is landed.', + ) + assert.deepEqual(diagnostic.nextSteps, [ + 'Open Step 2 to generate the simulation environment, then continue into Step 3 with the direct backend.', + 'After Step 2/3 has produced a simulation environment, Step 5 can still be used for role interaction even without a Step 4 report.', + 'Step 1 graph build and Step 4 graph-backed report tools remain blocked until ZEP_API_KEY is configured or a non-Zep graph backend is added.', + ]) + assert.deepEqual(diagnostic.rows.slice(-4), [ + { label: 'Direct LLM usage', value: 'Ready' }, + { label: 'Step 1 graph build', value: 'Needs ZEP_API_KEY' }, + { label: 'Step 4 graph-backed report tools', value: 'Needs ZEP_API_KEY' }, + { label: 'Step 5 on existing simulation', value: 'Ready when an existing simulation environment is available' }, + ]) +}) + +test('buildBackendDiagnosticModel flags conflicting base URL aliases', () => { + const diagnostic = buildBackendDiagnosticModel({ + summary: { + llm: { + configured: true, + backend_mode: 'openai_compatible', + base_url: 'https://api.openai.com/v1', + model: 'gpt-4.1-mini', + sources: { + api_key_env: 'OPENAI_API_KEY', + base_url_env: 'OPENAI_BASE_URL', + model_env: 'OPENAI_MODEL', + base_url_conflict: { + has_conflict: true, + selected_env: 'OPENAI_BASE_URL', + selected_value: 'https://api.openai.com/v1', + configured_envs: [ + { name: 'OPENAI_BASE_URL', value: 'https://api.openai.com/v1' }, + { name: 'OPENAI_API_BASE_URL', value: 'https://codex-gateway.example.test/v1' }, + ], + }, + uses_openai_aliases: true, + uses_project_aliases: false, + }, + }, + }, + validation: { + is_valid: true, + }, + }, t) + + assert.equal(diagnostic.tone, 'warning') + assert.equal(diagnostic.headline, 'Conflicting backend base URLs detected') + assert.equal( + diagnostic.note, + 'OPENAI_BASE_URL / OPENAI_API_BASE_URL are set to different values. MiroFish is currently using OPENAI_BASE_URL=https://api.openai.com/v1.', + ) + assert.deepEqual(diagnostic.nextSteps, []) +}) diff --git a/frontend/tests/apiErrors.test.mjs b/frontend/tests/apiErrors.test.mjs new file mode 100644 index 00000000..99479da2 --- /dev/null +++ b/frontend/tests/apiErrors.test.mjs @@ -0,0 +1,75 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { formatApiError } from '../src/api/errors.js' + +const t = (key, params = {}) => { + const messages = { + 'process.unknownError': 'Unknown error', + 'process.requestTimeout': 'Request timed out', + 'process.backendUnavailable': 'Backend unavailable at {apiBase}', + 'process.backendConfigIncomplete': 'Backend configuration is incomplete: {details}', + 'process.missingConfigKey': '{name} is not configured', + 'apiConfig.diagnostics.zepMissingNote': 'The direct LLM path is configured, but Step 1 graph build and graph-backed report tools still require ZEP_API_KEY until a non-Zep backend is landed.', + } + + let message = messages[key] + for (const [paramKey, value] of Object.entries(params)) { + message = message.replace(`{${paramKey}}`, String(value)) + } + return message +} + +test('formatApiError appends direct-LLM capability guidance for Zep-gated report failures', () => { + const message = formatApiError({ + err: { + response: { + data: { + error: 'Backend configuration is incomplete: ZEP_API_KEY is not configured', + data: { + summary: { + capabilities: { + direct_llm: { ready: true }, + graph_report_tools: { ready: false, requires_zep: true }, + }, + }, + }, + }, + }, + }, + t, + }) + + assert.equal( + message, + 'Backend configuration is incomplete: ZEP_API_KEY is not configured The direct LLM path is configured, but Step 1 graph build and graph-backed report tools still require ZEP_API_KEY until a non-Zep backend is landed.', + ) +}) + +test('formatApiError localizes nested backend config messages', () => { + const message = formatApiError({ + err: { + response: { + data: { + error: '后端配置不完整: OPENAI_API_KEY 未配置', + }, + }, + }, + t, + }) + + assert.equal(message, 'Backend configuration is incomplete: OPENAI_API_KEY is not configured') +}) + +test('formatApiError resolves backend unavailable messages against the active base url', () => { + const message = formatApiError({ + err: { + message: 'Network Error', + }, + t, + resolveBaseURL: () => 'https://api.example.test', + locationOrigin: 'http://localhost:5173', + }) + + assert.equal(message, 'Backend unavailable at https://api.example.test') +}) diff --git a/frontend/tests/baseUrl.test.mjs b/frontend/tests/baseUrl.test.mjs new file mode 100644 index 00000000..211680dd --- /dev/null +++ b/frontend/tests/baseUrl.test.mjs @@ -0,0 +1,75 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + API_BASE_OVERRIDE_KEY, + clearStoredBaseURL, + getStoredBaseURL, + resolveBaseURL, + setStoredBaseURL +} from '../src/api/baseUrl.js' + +test('prefers explicit VITE_API_BASE_URL', () => { + assert.equal( + resolveBaseURL({ + envBaseURL: ' https://api.example.com/v1 ', + location: new URL('http://localhost:3000') + }), + 'https://api.example.com/v1' + ) +}) + +test('prefers persisted runtime override over env and location fallback', () => { + assert.equal( + resolveBaseURL({ + runtimeBaseURL: ' https://runtime.example.com/api/ ', + envBaseURL: 'https://env.example.com/api', + location: new URL('http://localhost:3000') + }), + 'https://runtime.example.com/api' + ) +}) + +test('rewrites the documented frontend port 3000 to backend port 5001', () => { + assert.equal( + resolveBaseURL({ + location: new URL('http://127.0.0.1:3000/process') + }), + 'http://127.0.0.1:5001' + ) +}) + +test('keeps same-origin fallback for reverse-proxied deployments', () => { + assert.equal( + resolveBaseURL({ + location: new URL('https://mirofish.example.com/app') + }), + 'https://mirofish.example.com' + ) +}) + +test('persists a normalized runtime override in storage', () => { + const storage = new Map() + const mockStorage = { + getItem(key) { + return storage.has(key) ? storage.get(key) : null + }, + setItem(key, value) { + storage.set(key, value) + }, + removeItem(key) { + storage.delete(key) + } + } + + const savedValue = setStoredBaseURL(' https://api.example.com/root/ ', mockStorage) + + assert.equal(savedValue, 'https://api.example.com/root') + assert.equal(storage.get(API_BASE_OVERRIDE_KEY), 'https://api.example.com/root') + assert.equal(getStoredBaseURL(mockStorage), 'https://api.example.com/root') + + clearStoredBaseURL(mockStorage) + + assert.equal(getStoredBaseURL(mockStorage), '') + assert.equal(storage.has(API_BASE_OVERRIDE_KEY), false) +}) diff --git a/frontend/tests/clipboard.test.mjs b/frontend/tests/clipboard.test.mjs new file mode 100644 index 00000000..ecda089c --- /dev/null +++ b/frontend/tests/clipboard.test.mjs @@ -0,0 +1,36 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { copyText } from '../src/utils/clipboard.js' + +test('copyText writes to the provided clipboard object', async () => { + const calls = [] + const clipboard = { + async writeText(value) { + calls.push(value) + }, + } + + const copied = await copyText('report_123', clipboard) + + assert.equal(copied, true) + assert.deepEqual(calls, ['report_123']) +}) + +test('copyText returns false when the value is blank', async () => { + const clipboard = { + async writeText() { + throw new Error('should not be called') + }, + } + + await assert.doesNotReject(async () => { + const copied = await copyText('', clipboard) + assert.equal(copied, false) + }) +}) + +test('copyText returns false when clipboard support is unavailable', async () => { + const copied = await copyText('sim_123', null) + assert.equal(copied, false) +}) diff --git a/frontend/tests/errors.test.mjs b/frontend/tests/errors.test.mjs new file mode 100644 index 00000000..e0f7c852 --- /dev/null +++ b/frontend/tests/errors.test.mjs @@ -0,0 +1,61 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { formatApiError } from '../src/api/errors.js' + +const t = (key, params = {}) => { + const messages = { + 'process.unknownError': 'Unknown error', + 'process.requestTimeout': 'Timed out', + 'process.backendUnavailable': `Backend unavailable at ${params.apiBase}`, + 'process.backendConfigIncomplete': `Backend configuration is incomplete: ${params.details}`, + 'process.missingConfigKey': `${params.name} is not configured`, + } + return messages[key] +} + +test('returns backend error payload when present', () => { + const message = formatApiError({ + err: { + message: 'Error', + response: { + data: { + error: '后端配置不完整: ZEP_API_KEY 未配置' + } + } + }, + t, + resolveBaseURL: () => 'http://localhost:5001', + locationOrigin: 'http://localhost:3000' + }) + + assert.equal(message, 'Backend configuration is incomplete: ZEP_API_KEY is not configured') +}) + +test('formats network errors with resolved backend url', () => { + const message = formatApiError({ + err: { message: 'Network Error' }, + t, + resolveBaseURL: () => 'https://api.example.test', + locationOrigin: 'http://localhost:3000' + }) + + assert.equal(message, 'Backend unavailable at https://api.example.test') +}) + +test('passes through unknown backend payloads unchanged', () => { + const message = formatApiError({ + err: { + response: { + data: { + error: '图谱构建失败: custom provider exploded' + } + } + }, + t, + resolveBaseURL: () => 'https://api.example.test', + locationOrigin: 'http://localhost:3000' + }) + + assert.equal(message, '图谱构建失败: custom provider exploded') +}) diff --git a/frontend/tests/graphAliasDetails.test.mjs b/frontend/tests/graphAliasDetails.test.mjs new file mode 100644 index 00000000..a3cc499e --- /dev/null +++ b/frontend/tests/graphAliasDetails.test.mjs @@ -0,0 +1,26 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { getDisplayedAliasNames, hasMergedAliases } from '../src/components/graphAliasDetails.js' + +test('getDisplayedAliasNames removes the canonical node name and preserves alias order', () => { + const aliases = getDisplayedAliasNames({ + name: '特朗普', + alias_names: ['特朗普', '美国总统特朗普', 'Donald Trump'], + }) + + assert.deepEqual(aliases, ['美国总统特朗普', 'Donald Trump']) + assert.equal(hasMergedAliases({ name: '特朗普', alias_names: ['特朗普', '美国总统特朗普'] }), true) +}) + +test('getDisplayedAliasNames tolerates missing data and duplicate aliases', () => { + assert.deepEqual(getDisplayedAliasNames(null), []) + assert.deepEqual( + getDisplayedAliasNames({ + name: 'Alice', + alias_names: ['Alice', 'Alice', 'Alice Chen'], + }), + ['Alice Chen'], + ) + assert.equal(hasMergedAliases({ name: 'Alice', alias_names: ['Alice'] }), false) +}) diff --git a/frontend/tests/graphPanelData.test.mjs b/frontend/tests/graphPanelData.test.mjs new file mode 100644 index 00000000..d1738eb0 --- /dev/null +++ b/frontend/tests/graphPanelData.test.mjs @@ -0,0 +1,94 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { normalizeGraphPanelData, summarizeGraphData } from '../src/components/graphPanelData.js' + +test('graph panel data collapses duplicate aliases and remaps shared edges', () => { + const result = normalizeGraphPanelData({ + unnamedNodeLabel: 'Untitled', + unknownNodeLabel: 'Unknown', + graphData: { + nodes: [ + { uuid: 'node-1', name: '美国总统特朗普', labels: ['Entity', 'Person'], attributes: { title: 'President' } }, + { uuid: 'node-2', name: '特朗普', labels: ['Entity', 'Person'], summary: 'Shorter canonical label' }, + { uuid: 'node-3', name: '美国', labels: ['Entity', 'Location'] }, + ], + edges: [ + { source_node_uuid: 'node-1', target_node_uuid: 'node-3', fact_type: 'LEADS' }, + { source_node_uuid: 'node-2', target_node_uuid: 'node-3', fact_type: 'LEADS' }, + ], + }, + }) + + assert.deepEqual(result.nodes, [ + { + id: 'node-2', + name: '特朗普', + type: 'Person', + rawData: { + uuid: 'node-2', + name: '特朗普', + labels: ['Entity', 'Person'], + summary: 'Shorter canonical label', + attributes: { title: 'President' }, + alias_names: ['特朗普', '美国总统特朗普'], + merged_node_uuids: ['node-2', 'node-1'], + }, + }, + { + id: 'node-3', + name: '美国', + type: 'Location', + rawData: { + uuid: 'node-3', + name: '美国', + labels: ['Entity', 'Location'], + }, + }, + ]) + + assert.deepEqual(result.edges, [ + { + source: 'node-2', + target: 'node-3', + type: 'LEADS', + rawData: { + source_node_uuid: 'node-2', + target_node_uuid: 'node-3', + fact_type: 'LEADS', + source_name: '特朗普', + target_name: '美国', + }, + }, + ]) + + assert.deepEqual(result.entityTypes, [ + { name: 'Person', count: 1, color: '#FF6B35' }, + { name: 'Location', count: 1, color: '#004E89' }, + ]) +}) + +test('graph panel summary reports deduplicated node and edge counts', () => { + const result = summarizeGraphData({ + unnamedNodeLabel: 'Untitled', + unknownNodeLabel: 'Unknown', + graphData: { + nodes: [ + { uuid: 'node-1', name: '美国总统特朗普', labels: ['Entity', 'Person'] }, + { uuid: 'node-2', name: '特朗普', labels: ['Entity', 'Person'] }, + { uuid: 'node-3', name: '美国', labels: ['Entity', 'Location'] }, + ], + edges: [ + { source_node_uuid: 'node-1', target_node_uuid: 'node-3', fact_type: 'LEADS' }, + { source_node_uuid: 'node-2', target_node_uuid: 'node-3', fact_type: 'LEADS' }, + ], + }, + }) + + assert.equal(result.nodeCount, 2) + assert.equal(result.edgeCount, 1) + assert.deepEqual(result.entityTypes, [ + { name: 'Person', count: 1, color: '#FF6B35' }, + { name: 'Location', count: 1, color: '#004E89' }, + ]) +}) diff --git a/frontend/tests/historyFormatters.test.mjs b/frontend/tests/historyFormatters.test.mjs new file mode 100644 index 00000000..2852216c --- /dev/null +++ b/frontend/tests/historyFormatters.test.mjs @@ -0,0 +1,20 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { truncateFilename } from '../src/components/historyFormatters.js' + +test('truncateFilename returns the localized fallback when filename is missing', () => { + assert.equal(truncateFilename('', 20, 'Unknown file'), 'Unknown file') + assert.equal(truncateFilename(null, 20, '未知文件'), '未知文件') +}) + +test('truncateFilename preserves short filenames', () => { + assert.equal(truncateFilename('notes.md', 20, 'Unknown file'), 'notes.md') +}) + +test('truncateFilename truncates long filenames while preserving the extension', () => { + assert.equal( + truncateFilename('very-long-simulation-notes.md', 20, 'Unknown file'), + 'very-long-simu....md' + ) +}) diff --git a/frontend/tests/historyInteractionRoute.test.mjs b/frontend/tests/historyInteractionRoute.test.mjs new file mode 100644 index 00000000..b63108db --- /dev/null +++ b/frontend/tests/historyInteractionRoute.test.mjs @@ -0,0 +1,18 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { buildInteractionRoute } from '../src/components/interactionRoute.js' + +test('history interaction prefers the report-backed Step 5 route when available', () => { + assert.deepEqual(buildInteractionRoute({ reportId: 'report_123', simulationId: 'sim_456' }), { + name: 'Interaction', + params: { reportId: 'report_123' }, + }) +}) + +test('history interaction falls back to the simulation-only Step 5 route', () => { + assert.deepEqual(buildInteractionRoute({ reportId: '', simulationId: 'sim_456' }), { + name: 'InteractionSimulation', + params: { simulationId: 'sim_456' }, + }) +}) diff --git a/frontend/tests/historyPlayback.test.mjs b/frontend/tests/historyPlayback.test.mjs new file mode 100644 index 00000000..ef17bf8a --- /dev/null +++ b/frontend/tests/historyPlayback.test.mjs @@ -0,0 +1,54 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + buildSimulationReplayRoute, + hasReplayableSimulationState, +} from '../src/components/historyPlayback.js' +import { + getRestartButtonLabelKey, + isReplayOnlyRoute, + getReplayNoticeKey, + shouldAutoStartSimulation, +} from '../src/components/simulationReplay.js' + +test('history playback detects replayable simulation states', () => { + assert.equal(hasReplayableSimulationState({ runner_status: 'idle', current_round: 0, total_rounds: 0 }), false) + assert.equal(hasReplayableSimulationState({ runner_status: 'completed', current_round: 12, total_rounds: 12 }), true) + assert.equal(hasReplayableSimulationState({ runner_status: 'failed', current_round: 3, total_rounds: 12 }), true) + assert.equal(hasReplayableSimulationState({ runner_status: 'idle', current_round: 2, total_rounds: 12 }), true) +}) + +test('history playback builds a replay-only Step 3 route', () => { + assert.deepEqual(buildSimulationReplayRoute('sim_123'), { + name: 'SimulationRun', + params: { simulationId: 'sim_123' }, + query: { replay: '1' }, + }) +}) + +test('simulation replay helpers block auto-start in replay-only mode', () => { + assert.equal(isReplayOnlyRoute('1'), true) + assert.equal(isReplayOnlyRoute('true'), true) + assert.equal(isReplayOnlyRoute(undefined), false) + + assert.equal(shouldAutoStartSimulation({ replayOnly: true, resumed: false }), false) + assert.equal(shouldAutoStartSimulation({ replayOnly: false, resumed: true }), false) + assert.equal(shouldAutoStartSimulation({ replayOnly: false, resumed: false }), true) +}) + +test('simulation replay helpers expose visible replay limitation notices', () => { + assert.equal(getReplayNoticeKey({ replayOnly: false, resumed: false, runnerStatus: '' }), null) + assert.equal(getReplayNoticeKey({ replayOnly: true, resumed: false, runnerStatus: '' }), 'step3.replayOnlyNoRunNotice') + assert.equal(getReplayNoticeKey({ replayOnly: true, resumed: true, runnerStatus: 'failed' }), 'step3.replayOnlyFailedNotice') + assert.equal(getReplayNoticeKey({ replayOnly: true, resumed: true, runnerStatus: 'stopped' }), 'step3.replayOnlyStoppedNotice') + assert.equal(getReplayNoticeKey({ replayOnly: true, resumed: true, runnerStatus: 'completed' }), null) +}) + +test('simulation replay helpers choose restart labels for prepared replay states', () => { + assert.equal(getRestartButtonLabelKey({ replayOnly: false, resumed: false, runnerStatus: '' }), 'step3.restartSimulation') + assert.equal(getRestartButtonLabelKey({ replayOnly: true, resumed: false, runnerStatus: '' }), 'step3.startPreparedSimulation') + assert.equal(getRestartButtonLabelKey({ replayOnly: true, resumed: true, runnerStatus: 'failed' }), 'step3.restartPreparedSimulation') + assert.equal(getRestartButtonLabelKey({ replayOnly: true, resumed: true, runnerStatus: 'stopped' }), 'step3.restartPreparedSimulation') + assert.equal(getRestartButtonLabelKey({ replayOnly: true, resumed: true, runnerStatus: 'completed' }), 'step3.restartSimulation') +}) diff --git a/frontend/tests/historyReportDownload.test.mjs b/frontend/tests/historyReportDownload.test.mjs new file mode 100644 index 00000000..03d5d565 --- /dev/null +++ b/frontend/tests/historyReportDownload.test.mjs @@ -0,0 +1,74 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + buildHistoryReportDownloadFilename, + buildHistoryReportDownloadUrl, + triggerHistoryReportDownload, +} from '../src/components/historyReportDownload.js' + +test('buildHistoryReportDownloadUrl trims the base URL and encodes report IDs', () => { + assert.equal( + buildHistoryReportDownloadUrl('report alpha/1', 'https://example.test/base/'), + 'https://example.test/base/api/report/report%20alpha%2F1/download' + ) +}) + +test('buildHistoryReportDownloadFilename includes report and simulation references', () => { + assert.equal( + buildHistoryReportDownloadFilename('report alpha/1', 'sim primary/2'), + 'mirofish-report-report-alpha-1--simulation-sim-primary-2.md' + ) + assert.equal( + buildHistoryReportDownloadFilename('report_123', ''), + 'mirofish-report-report_123.md' + ) +}) + +test('triggerHistoryReportDownload creates and clicks a temporary anchor', () => { + const appended = [] + const removed = [] + const anchor = { + href: '', + download: '', + rel: '', + style: {}, + clicked: false, + click() { + this.clicked = true + }, + remove() { + removed.push(this.download) + }, + } + const documentRef = { + body: { + appendChild(node) { + appended.push(node) + }, + }, + createElement(tag) { + assert.equal(tag, 'a') + return anchor + }, + } + + const downloaded = triggerHistoryReportDownload('report_123', { + simulationId: 'sim_456', + baseURL: 'https://mirofish.example.test/', + documentRef, + }) + + assert.equal(downloaded, true) + assert.equal(anchor.href, 'https://mirofish.example.test/api/report/report_123/download') + assert.equal(anchor.download, 'mirofish-report-report_123--simulation-sim_456.md') + assert.equal(anchor.rel, 'noopener') + assert.equal(anchor.clicked, true) + assert.deepEqual(appended, [anchor]) + assert.deepEqual(removed, ['mirofish-report-report_123--simulation-sim_456.md']) +}) + +test('triggerHistoryReportDownload returns false when report ID or document is unavailable', () => { + assert.equal(triggerHistoryReportDownload('', { baseURL: 'https://example.test', documentRef: null }), false) + assert.equal(triggerHistoryReportDownload('report_123', { baseURL: 'https://example.test', documentRef: null }), false) +}) diff --git a/frontend/tests/i18n.test.mjs b/frontend/tests/i18n.test.mjs new file mode 100644 index 00000000..3bf83fe9 --- /dev/null +++ b/frontend/tests/i18n.test.mjs @@ -0,0 +1,71 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { getStoredLocale, normalizeLocale, resolveBrowserLocale } from '../src/i18n/index.js' + +const originalWindow = global.window + +test.afterEach(() => { + if (originalWindow === undefined) { + delete global.window + } else { + global.window = originalWindow + } +}) + +test('normalizeLocale maps supported language tags to app locales', () => { + assert.equal(normalizeLocale('en-US'), 'en') + assert.equal(normalizeLocale('zh-CN'), 'zh') + assert.equal(normalizeLocale(' fr-FR '), null) +}) + +test('resolveBrowserLocale falls back to zh when browser language is unsupported', () => { + assert.equal(resolveBrowserLocale('fr-FR'), 'zh') + assert.equal(resolveBrowserLocale(undefined), 'zh') +}) + +test('getStoredLocale prefers an explicit stored locale', () => { + global.window = { + localStorage: { + getItem(key) { + assert.equal(key, 'mirofish-locale') + return 'zh' + }, + }, + navigator: { + language: 'en-US', + }, + } + + assert.equal(getStoredLocale(), 'zh') +}) + +test('getStoredLocale uses the browser locale on first run when no preference is saved', () => { + global.window = { + localStorage: { + getItem() { + return null + }, + }, + navigator: { + language: 'en-US', + }, + } + + assert.equal(getStoredLocale(), 'en') +}) + +test('getStoredLocale falls back to zh when storage fails and browser locale is unsupported', () => { + global.window = { + localStorage: { + getItem() { + throw new Error('storage unavailable') + }, + }, + navigator: { + language: 'fr-FR', + }, + } + + assert.equal(getStoredLocale(), 'zh') +}) diff --git a/frontend/tests/interactionRoute.test.mjs b/frontend/tests/interactionRoute.test.mjs new file mode 100644 index 00000000..49a37d14 --- /dev/null +++ b/frontend/tests/interactionRoute.test.mjs @@ -0,0 +1,22 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { buildInteractionRoute } from '../src/components/interactionRoute.js' + +test('buildInteractionRoute prefers report routes when a report id exists', () => { + assert.deepEqual(buildInteractionRoute({ reportId: 'report_123', simulationId: 'sim_456' }), { + name: 'Interaction', + params: { reportId: 'report_123' }, + }) +}) + +test('buildInteractionRoute falls back to simulation-only interaction routes', () => { + assert.deepEqual(buildInteractionRoute({ simulationId: 'sim_456' }), { + name: 'InteractionSimulation', + params: { simulationId: 'sim_456' }, + }) +}) + +test('buildInteractionRoute returns null when no usable identifiers exist', () => { + assert.equal(buildInteractionRoute({ reportId: ' ', simulationId: '' }), null) +}) diff --git a/frontend/tests/liveActionBuffer.test.mjs b/frontend/tests/liveActionBuffer.test.mjs new file mode 100644 index 00000000..d42603ce --- /dev/null +++ b/frontend/tests/liveActionBuffer.test.mjs @@ -0,0 +1,101 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + buildLiveActionId, + mergeLiveActions, +} from '../src/components/liveActionBuffer.js' + +test('buildLiveActionId prefers server id when present', () => { + assert.equal( + buildLiveActionId({ + id: 'action-1', + timestamp: '2026-03-11T14:00:00', + platform: 'twitter', + agent_id: 1, + action_type: 'CREATE_POST', + }), + 'action-1' + ) +}) + +test('mergeLiveActions deduplicates incoming events and keeps the latest timestamp', () => { + const merged = mergeLiveActions({ + incomingActions: [ + { + timestamp: '2026-03-11T14:00:00', + platform: 'twitter', + agent_id: 1, + action_type: 'CREATE_POST', + }, + { + timestamp: '2026-03-11T14:00:00', + platform: 'twitter', + agent_id: 1, + action_type: 'CREATE_POST', + }, + { + timestamp: '2026-03-11T14:00:01', + platform: 'reddit', + agent_id: 2, + action_type: 'CREATE_COMMENT', + }, + ], + }) + + assert.equal(merged.actions.length, 2) + assert.deepEqual( + merged.actions.map((action) => action._uniqueId), + [ + '2026-03-11T14:00:00-twitter-1-CREATE_POST', + '2026-03-11T14:00:01-reddit-2-CREATE_COMMENT', + ] + ) + assert.equal(merged.latestActionTimestamp, '2026-03-11T14:00:01') +}) + +test('mergeLiveActions trims the oldest buffered events when the cap is exceeded', () => { + const merged = mergeLiveActions({ + existingActions: [ + { + timestamp: '2026-03-11T14:00:00', + platform: 'twitter', + agent_id: 1, + action_type: 'CREATE_POST', + _uniqueId: '2026-03-11T14:00:00-twitter-1-CREATE_POST', + }, + { + timestamp: '2026-03-11T14:00:01', + platform: 'reddit', + agent_id: 2, + action_type: 'CREATE_COMMENT', + _uniqueId: '2026-03-11T14:00:01-reddit-2-CREATE_COMMENT', + }, + ], + existingIds: new Set([ + '2026-03-11T14:00:00-twitter-1-CREATE_POST', + '2026-03-11T14:00:01-reddit-2-CREATE_COMMENT', + ]), + incomingActions: [ + { + timestamp: '2026-03-11T14:00:02', + platform: 'twitter', + agent_id: 3, + action_type: 'LIKE_POST', + }, + ], + maxActions: 2, + }) + + assert.deepEqual( + merged.actions.map((action) => action._uniqueId), + [ + '2026-03-11T14:00:01-reddit-2-CREATE_COMMENT', + '2026-03-11T14:00:02-twitter-3-LIKE_POST', + ] + ) + assert.equal( + merged.actionIds.has('2026-03-11T14:00:00-twitter-1-CREATE_POST'), + false + ) +}) diff --git a/frontend/tests/mainViewLogMessages.test.mjs b/frontend/tests/mainViewLogMessages.test.mjs new file mode 100644 index 00000000..8d9f27d6 --- /dev/null +++ b/frontend/tests/mainViewLogMessages.test.mjs @@ -0,0 +1,28 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + formatMainViewGraphRefreshLog, + formatMainViewStepLog, +} from '../src/views/mainViewLogMessages.js' + +const t = (key, params = {}) => `${key}:${JSON.stringify(params)}` + +test('main view step logs localize forward and backward workflow transitions', () => { + assert.equal( + formatMainViewStepLog('enter', 2, 'Environment Setup', t), + 'mainView.logs.enterStep:{"step":2,"name":"Environment Setup"}' + ) + + assert.equal( + formatMainViewStepLog('back', 1, 'Graph Build', t), + 'mainView.logs.returnStep:{"step":1,"name":"Graph Build"}' + ) +}) + +test('main view graph refresh log keeps translated labels and counts centralized', () => { + assert.equal( + formatMainViewGraphRefreshLog(12, 34, t), + 'mainView.logs.graphDataRefreshed:{"nodeCount":12,"edgeCount":34}' + ) +}) diff --git a/frontend/tests/processGraphData.test.mjs b/frontend/tests/processGraphData.test.mjs new file mode 100644 index 00000000..d2c7709b --- /dev/null +++ b/frontend/tests/processGraphData.test.mjs @@ -0,0 +1,169 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + getProcessGraphSignature, + mapProcessGraphData, +} from '../src/views/processGraphData.js' + +test('process graph mapping localizes fallback node and edge labels', () => { + const result = mapProcessGraphData({ + unnamedNodeLabel: 'Untitled', + unknownNodeLabel: 'Unknown', + nodes: [ + { uuid: 'node-1', name: '', labels: ['Entity', 'Person'] }, + { uuid: 'node-2', name: 'Alice', labels: ['Entity', 'Person'] }, + ], + edges: [ + { source_node_uuid: 'node-1', target_node_uuid: 'node-2', fact_type: 'KNOWS' }, + { source_node_uuid: 'node-3', target_node_uuid: 'node-2', fact_type: 'IGNORED' }, + ], + }) + + assert.deepEqual(result.nodes, [ + { + id: 'node-1', + name: 'Untitled', + type: 'Person', + rawData: { uuid: 'node-1', name: '', labels: ['Entity', 'Person'] }, + }, + { + id: 'node-2', + name: 'Alice', + type: 'Person', + rawData: { uuid: 'node-2', name: 'Alice', labels: ['Entity', 'Person'] }, + }, + ]) + + assert.deepEqual(result.edges, [ + { + source: 'node-1', + target: 'node-2', + type: 'KNOWS', + rawData: { + source_node_uuid: 'node-1', + target_node_uuid: 'node-2', + fact_type: 'KNOWS', + source_name: 'Unknown', + target_name: 'Alice', + }, + }, + ]) +}) + +test('process graph mapping collapses obvious alias duplicates and remaps edges', () => { + const result = mapProcessGraphData({ + unnamedNodeLabel: 'Untitled', + unknownNodeLabel: 'Unknown', + nodes: [ + { uuid: 'node-1', name: '美国总统特朗普', labels: ['Entity', 'Person'], attributes: { title: 'President' } }, + { uuid: 'node-2', name: '特朗普', labels: ['Entity', 'Person'], summary: 'Shorter canonical label' }, + { uuid: 'node-3', name: '美国', labels: ['Entity', 'Location'] }, + ], + edges: [ + { source_node_uuid: 'node-1', target_node_uuid: 'node-3', fact_type: 'LEADS' }, + { source_node_uuid: 'node-2', target_node_uuid: 'node-3', fact_type: 'LEADS' }, + ], + }) + + assert.deepEqual(result.nodes, [ + { + id: 'node-2', + name: '特朗普', + type: 'Person', + rawData: { + uuid: 'node-2', + name: '特朗普', + labels: ['Entity', 'Person'], + summary: 'Shorter canonical label', + attributes: { title: 'President' }, + alias_names: ['特朗普', '美国总统特朗普'], + merged_node_uuids: ['node-2', 'node-1'], + }, + }, + { + id: 'node-3', + name: '美国', + type: 'Location', + rawData: { + uuid: 'node-3', + name: '美国', + labels: ['Entity', 'Location'], + }, + }, + ]) + + assert.deepEqual(result.edges, [ + { + source: 'node-2', + target: 'node-3', + type: 'LEADS', + rawData: { + source_node_uuid: 'node-2', + target_node_uuid: 'node-3', + fact_type: 'LEADS', + source_name: '特朗普', + target_name: '美国', + }, + }, + ]) +}) + +test('process graph signature changes when edges change without node-count delta', () => { + const basePayload = { + unnamedNodeLabel: 'Untitled', + unknownNodeLabel: 'Unknown', + nodes: [ + { uuid: 'node-1', name: 'Alice', labels: ['Entity', 'Person'] }, + { uuid: 'node-2', name: 'Bob', labels: ['Entity', 'Person'] }, + { uuid: 'node-3', name: 'Carol', labels: ['Entity', 'Person'] }, + ], + } + + const firstSignature = getProcessGraphSignature({ + ...basePayload, + edges: [ + { source_node_uuid: 'node-1', target_node_uuid: 'node-2', fact_type: 'KNOWS' }, + ], + }) + const secondSignature = getProcessGraphSignature({ + ...basePayload, + edges: [ + { source_node_uuid: 'node-1', target_node_uuid: 'node-3', fact_type: 'KNOWS' }, + ], + }) + + assert.notEqual(firstSignature, secondSignature) +}) + +test('process graph signature is stable across raw ordering changes after alias collapse', () => { + const payloadA = getProcessGraphSignature({ + unnamedNodeLabel: 'Untitled', + unknownNodeLabel: 'Unknown', + nodes: [ + { uuid: 'node-1', name: '美国总统特朗普', labels: ['Entity', 'Person'] }, + { uuid: 'node-2', name: '特朗普', labels: ['Entity', 'Person'] }, + { uuid: 'node-3', name: '美国', labels: ['Entity', 'Location'] }, + ], + edges: [ + { source_node_uuid: 'node-1', target_node_uuid: 'node-3', fact_type: 'LEADS' }, + { source_node_uuid: 'node-2', target_node_uuid: 'node-3', fact_type: 'LEADS' }, + ], + }) + + const payloadB = getProcessGraphSignature({ + unnamedNodeLabel: 'Untitled', + unknownNodeLabel: 'Unknown', + nodes: [ + { uuid: 'node-3', name: '美国', labels: ['Entity', 'Location'] }, + { uuid: 'node-2', name: '特朗普', labels: ['Entity', 'Person'] }, + { uuid: 'node-1', name: '美国总统特朗普', labels: ['Entity', 'Person'] }, + ], + edges: [ + { source_node_uuid: 'node-2', target_node_uuid: 'node-3', fact_type: 'LEADS' }, + { source_node_uuid: 'node-1', target_node_uuid: 'node-3', fact_type: 'LEADS' }, + ], + }) + + assert.equal(payloadA, payloadB) +}) diff --git a/frontend/tests/reportCapability.test.mjs b/frontend/tests/reportCapability.test.mjs new file mode 100644 index 00000000..1cd3e9ba --- /dev/null +++ b/frontend/tests/reportCapability.test.mjs @@ -0,0 +1,47 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { getReportPreflightBlockReason } from '../src/components/reportCapability.js' + +const t = (key) => { + const messages = { + 'apiConfig.diagnostics.zepMissingNote': 'The direct LLM path is configured, but Step 1 graph build and graph-backed report tools still require ZEP_API_KEY until a non-Zep backend is landed.', + 'apiConfig.diagnostics.capabilityNeedsExistingSimulation': 'Needs an existing simulation environment', + 'apiConfig.diagnostics.capabilityNeedsBackendConfig': 'Needs backend config', + } + + return messages[key] +} + +test('getReportPreflightBlockReason returns empty when report tools are ready', () => { + assert.equal(getReportPreflightBlockReason({ + summary: { + capabilities: { + direct_llm: { ready: true }, + graph_report_tools: { ready: true, requires_zep: true }, + }, + }, + }, t), '') +}) + +test('getReportPreflightBlockReason returns the direct-LLM Zep warning when Step 4 is Zep-gated', () => { + assert.equal(getReportPreflightBlockReason({ + summary: { + capabilities: { + direct_llm: { ready: true }, + graph_report_tools: { ready: false, requires_zep: true }, + }, + }, + }, t), 'The direct LLM path is configured, but Step 1 graph build and graph-backed report tools still require ZEP_API_KEY until a non-Zep backend is landed.') +}) + +test('getReportPreflightBlockReason falls back to generic backend requirements when direct LLM is not ready', () => { + assert.equal(getReportPreflightBlockReason({ + summary: { + capabilities: { + direct_llm: { ready: false }, + graph_report_tools: { ready: false }, + }, + }, + }, t), 'Needs backend config') +}) diff --git a/frontend/tests/reportParsers.test.mjs b/frontend/tests/reportParsers.test.mjs new file mode 100644 index 00000000..2a5cc860 --- /dev/null +++ b/frontend/tests/reportParsers.test.mjs @@ -0,0 +1,341 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + extractFinalContent, + getInterviewAnswerForQuestion, + isMissingPlatformReply, + parseInterview, + parseInsightForge, + parsePanorama, + parseQuickSearch, +} from '../src/components/reportParsers.js' + +test('parseInterview supports the existing Chinese report-tool format', () => { + const parsed = parseInterview(`**采访主题:** 武大处分事件后续 +**采访人数:** 1 / 2 + +### 采访对象选择理由 +1. **校友_345(index=1)**:作为校友代表,能观察舆情变化。 + +### 采访实录 +#### 采访 #1: +校友 +**校友_345** (校友代表) +_简介: 持续关注学校舆情。_ + +**Q:** +1. 你怎么看? +2. 下一步会怎样? + +**A:** +【Twitter平台回答】 +问题1:会先出现激烈讨论。 + +问题2:随后会逐渐回归理性。 + +【Reddit平台回答】 +(该平台未获得回复) + +**关键引言:** +> "会先出现激烈讨论。" + +### 采访摘要与核心观点 +校友群体预计会先激烈讨论,再回归理性。`) + + assert.equal(parsed.topic, '武大处分事件后续') + assert.equal(parsed.agentCount, '1 / 2') + assert.equal(parsed.interviews[0].bio, '持续关注学校舆情。') + assert.deepEqual(parsed.interviews[0].questions, ['你怎么看?', '下一步会怎样?']) + assert.equal(getInterviewAnswerForQuestion(parsed.interviews[0], 0, 'twitter'), '会先出现激烈讨论。') + assert.equal(getInterviewAnswerForQuestion(parsed.interviews[0], 1, 'twitter'), '随后会逐渐回归理性。') + assert.equal(parsed.summary, '校友群体预计会先激烈讨论,再回归理性。') +}) + +test('parseInterview supports English headings and placeholders', () => { + const parsed = parseInterview(`**Interview Topic:** Campus policy fallout +**Interviewed Agents:** 1 / 3 + +### Why These Interviewees +1. **Alumni_12 (index=1)**: Represents the alumni audience. + +### Interview Transcript +#### Interview #1: +Alumni Voice +**Alumni_12** (Alumni) +_Bio: Follows every school policy thread._ + +**Q:** +1. What happens first? +2. What happens next? + +**A:** +【Twitter Reply】 +Question 1: The discussion spikes immediately. + +Question 2: It cools off after official clarification. + +【Reddit Reply】 +(No reply from this platform) + +**Key Quotes:** +> "The discussion spikes immediately." + +### Interview Summary and Key Takeaways +The audience reacts fast, then waits for clarification.`) + + assert.equal(parsed.topic, 'Campus policy fallout') + assert.equal(parsed.agentCount, '1 / 3') + assert.equal(parsed.interviews[0].selectionReason, 'Represents the alumni audience.') + assert.deepEqual(parsed.interviews[0].questions, ['What happens first?', 'What happens next?']) + assert.equal(getInterviewAnswerForQuestion(parsed.interviews[0], 0, 'twitter'), 'The discussion spikes immediately.') + assert.equal(getInterviewAnswerForQuestion(parsed.interviews[0], 1, 'twitter'), 'It cools off after official clarification.') + assert.equal(isMissingPlatformReply(parsed.interviews[0].redditAnswer), true) + assert.equal(parsed.summary, 'The audience reacts fast, then waits for clarification.') +}) + +test('parseInterview accepts alternate English section labels and question prefixes', () => { + const parsed = parseInterview(`**Topic:** Platform reaction outlook +**Agents Interviewed:** 1 / 2 + +### Selection Rationale +- Select Analyst_7: Covers cross-platform reaction patterns. + +### Interview Transcript +#### Interview #1: +Analyst Desk +**Analyst_7** (Policy Analyst) +_Profile: Tracks how narratives move between communities._ + +**Questions:** +Question 1: What is the first visible shift? +Question 2: What follows after clarification? + +**Answer:** +【Twitter Response】 +Question 1: Engagement spikes around the accusation. + +Question 2: Attention softens once official details arrive. + +【Reddit Response】 +[No Reply] + +**Quotes:** +> "Engagement spikes around the accusation." + +### Key Takeaways +The reaction peaks early, then moderates after clarification.`) + + assert.equal(parsed.topic, 'Platform reaction outlook') + assert.equal(parsed.agentCount, '1 / 2') + assert.equal(parsed.interviews[0].bio, 'Tracks how narratives move between communities.') + assert.equal(parsed.interviews[0].selectionReason, 'Covers cross-platform reaction patterns.') + assert.deepEqual(parsed.interviews[0].questions, [ + 'What is the first visible shift?', + 'What follows after clarification?', + ]) + assert.equal( + getInterviewAnswerForQuestion(parsed.interviews[0], 0, 'twitter'), + 'Engagement spikes around the accusation.' + ) + assert.equal( + getInterviewAnswerForQuestion(parsed.interviews[0], 1, 'twitter'), + 'Attention softens once official details arrive.' + ) + assert.equal(isMissingPlatformReply(parsed.interviews[0].redditAnswer), true) + assert.equal(parsed.summary, 'The reaction peaks early, then moderates after clarification.') +}) + +test('parseQuickSearch supports both Chinese and English formats', () => { + const chinese = parseQuickSearch(`搜索查询: 武大 处分 +找到 2 条相关事实 + +### 相关事实: +1. 第一条事实 +2. 第二条事实 + +### 相关边: +- 学校 --[发布]--> 通知 + +### 相关节点: +- **学校** (组织) +`) + + assert.equal(chinese.query, '武大 处分') + assert.equal(chinese.count, 2) + assert.deepEqual(chinese.facts, ['第一条事实', '第二条事实']) + assert.deepEqual(chinese.edges, [{ source: '学校', relation: '发布', target: '通知' }]) + assert.deepEqual(chinese.nodes, [{ name: '学校', type: '组织' }]) + + const english = parseQuickSearch(`Search Query: campus statement +Found 3 relevant facts + +### Relevant Facts: +1. Fact one +2. Fact two +3. Fact three + +### Related Edges: +- University --[issued]--> statement + +### Related Nodes: +- **University** (Organization) +- Student forum +`) + + assert.equal(english.query, 'campus statement') + assert.equal(english.count, 3) + assert.deepEqual(english.facts, ['Fact one', 'Fact two', 'Fact three']) + assert.deepEqual(english.edges, [{ source: 'University', relation: 'issued', target: 'statement' }]) + assert.deepEqual(english.nodes, [ + { name: 'University', type: 'Organization' }, + { name: 'Student forum', type: '' }, + ]) +}) + +test('parseInsightForge supports both Chinese and English formats', () => { + const chinese = parseInsightForge(`分析问题: 武大处分事件 +预测场景: 未来一周舆情如何变化 +相关预测事实: 2 +涉及实体: 1 +关系链: 1 + +### 分析的子问题 +1. 官方会不会回应? +2. 舆情会不会降温? + +### 【关键事实】 +1. "学校已经发布通报" +2. "讨论仍在持续" + +### 【核心实体】 +- **学校** (组织) +摘要: "事件主体" +相关事实: 2 + +### 【关系链】 +- 学校 --[发布]--> 通报 +`) + + assert.equal(chinese.query, '武大处分事件') + assert.equal(chinese.simulationRequirement, '未来一周舆情如何变化') + assert.deepEqual(chinese.stats, { facts: 2, entities: 1, relationships: 1 }) + assert.deepEqual(chinese.subQueries, ['官方会不会回应?', '舆情会不会降温?']) + assert.deepEqual(chinese.facts, ['学校已经发布通报', '讨论仍在持续']) + assert.deepEqual(chinese.entities, [ + { name: '学校', type: '组织', summary: '事件主体', relatedFactsCount: 2 }, + ]) + assert.deepEqual(chinese.relations, [ + { source: '学校', relation: '发布', target: '通报' }, + ]) + + const english = parseInsightForge(`Analysis Question: Campus discipline fallout +Prediction Scenario: How sentiment changes over the next week +Relevant Prediction Facts: 3 +Entities Involved: 2 +Relationship Chains: 1 + +### Analysis Subquestions +1. Will the school issue another statement? +2. Does discussion cool off after clarification? + +### Key Facts +1. "The university already issued a statement" +2. "Students are still debating online" + +### Core Entities +- **University** (Organization) +Summary: "Primary institution in the event" +Related Facts: 2 +- **Student Forum** (Community) +Summary: "Tracks the reaction" +Related Facts: 1 + +### Relationship Chains +- University --[issued]--> statement +`) + + assert.equal(english.query, 'Campus discipline fallout') + assert.equal(english.simulationRequirement, 'How sentiment changes over the next week') + assert.deepEqual(english.stats, { facts: 3, entities: 2, relationships: 1 }) + assert.deepEqual(english.subQueries, [ + 'Will the school issue another statement?', + 'Does discussion cool off after clarification?', + ]) + assert.deepEqual(english.facts, [ + 'The university already issued a statement', + 'Students are still debating online', + ]) + assert.deepEqual(english.entities, [ + { name: 'University', type: 'Organization', summary: 'Primary institution in the event', relatedFactsCount: 2 }, + { name: 'Student Forum', type: 'Community', summary: 'Tracks the reaction', relatedFactsCount: 1 }, + ]) + assert.deepEqual(english.relations, [ + { source: 'University', relation: 'issued', target: 'statement' }, + ]) +}) + +test('parsePanorama supports both Chinese and English formats', () => { + const chinese = parsePanorama(`查询: 武大舆情 +总节点数: 5 +总边数: 4 +当前有效事实: 2 +历史/过期事实: 1 + +### 【当前有效事实】 +1. "学校回应仍在传播" +2. "讨论热度开始下降" + +### 【历史/过期事实】 +1. "早期传言已失效" + +### 【涉及实体】 +- **学校** (组织) +- **校友** (群体) +`) + + assert.equal(chinese.query, '武大舆情') + assert.deepEqual(chinese.stats, { nodes: 5, edges: 4, activeFacts: 2, historicalFacts: 1 }) + assert.deepEqual(chinese.activeFacts, ['学校回应仍在传播', '讨论热度开始下降']) + assert.deepEqual(chinese.historicalFacts, ['早期传言已失效']) + assert.deepEqual(chinese.entities, [ + { name: '学校', type: '组织' }, + { name: '校友', type: '群体' }, + ]) + + const english = parsePanorama(`Query: campus sentiment +Total Nodes: 7 +Total Edges: 9 +Current Active Facts: 2 +Historical/Expired Facts: 1 + +### Current Active Facts +1. "Official clarification is still being shared" +2. "Most replies are less heated now" + +### Historical/Expired Facts +1. "An early rumor has been disproven" + +### Entities Involved +- **University** (Organization) +- **Alumni** (Audience) +`) + + assert.equal(english.query, 'campus sentiment') + assert.deepEqual(english.stats, { nodes: 7, edges: 9, activeFacts: 2, historicalFacts: 1 }) + assert.deepEqual(english.activeFacts, [ + 'Official clarification is still being shared', + 'Most replies are less heated now', + ]) + assert.deepEqual(english.historicalFacts, ['An early rumor has been disproven']) + assert.deepEqual(english.entities, [ + { name: 'University', type: 'Organization' }, + { name: 'Alumni', type: 'Audience' }, + ]) +}) + +test('extractFinalContent accepts English and Chinese final-answer markers', () => { + assert.equal(extractFinalContent('Final Answer:\nHello world'), 'Hello world') + assert.equal(extractFinalContent('最终答案:\n你好,世界'), '你好,世界') + assert.equal(extractFinalContent('<final_answer>done</final_answer>'), 'done') +}) diff --git a/frontend/tests/reportReferences.test.mjs b/frontend/tests/reportReferences.test.mjs new file mode 100644 index 00000000..6f4d37f8 --- /dev/null +++ b/frontend/tests/reportReferences.test.mjs @@ -0,0 +1,14 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { resolveReportReferenceValue } from '../src/components/reportReferences.js' + +test('resolveReportReferenceValue preserves real report IDs', () => { + assert.equal(resolveReportReferenceValue('report_123', 'Not available yet'), 'report_123') +}) + +test('resolveReportReferenceValue falls back to localized unavailable copy for blank IDs', () => { + assert.equal(resolveReportReferenceValue('', 'Not available yet'), 'Not available yet') + assert.equal(resolveReportReferenceValue(' ', '暂未生成'), '暂未生成') + assert.equal(resolveReportReferenceValue(null, 'Not available yet'), 'Not available yet') +}) diff --git a/frontend/tests/simulationLogMessages.test.mjs b/frontend/tests/simulationLogMessages.test.mjs new file mode 100644 index 00000000..7dd4bcee --- /dev/null +++ b/frontend/tests/simulationLogMessages.test.mjs @@ -0,0 +1,78 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + formatPrepareProgressLog, + formatSimulationPidLog, + formatSimulationRoundLog, + getPrepareStageLabel, +} from '../src/components/simulationLogMessages.js' + +const t = (key, params = {}) => `${key}:${JSON.stringify(params)}` + +test('prepare-stage helpers localize known stage codes', () => { + assert.equal( + getPrepareStageLabel('generating_profiles', '生成Agent人设', t), + 'step2.logs.stageLabels.generatingProfiles:{}' + ) + + assert.equal( + getPrepareStageLabel('custom_stage', 'Custom label', t), + 'Custom label' + ) +}) + +test('prepare progress log keeps stage counts and item text', () => { + assert.equal( + formatPrepareProgressLog( + { + current_stage: 'generating_config', + current_stage_name: '生成模拟配置', + current_item: 2, + total_items: 5, + item_description: 'Building recommendation config', + stage_index: 2, + total_stages: 3, + }, + t + ), + 'step2.logs.progressStageWithItems:{"current":2,"total":5,"stage":"step2.logs.stageLabels.generatingConfig:{}","item":"Building recommendation config","index":2,"stages":3}' + ) + + assert.equal( + formatPrepareProgressLog( + { + current_stage: 'copying_scripts', + current_stage_name: '准备模拟脚本', + current_item: 0, + total_items: 0, + item_description: 'Copying runtime files', + stage_index: 3, + total_stages: 3, + }, + t + ), + 'step2.logs.progressStageWithoutItems:{"current":0,"total":0,"stage":"step2.logs.stageLabels.copyingScripts:{}","item":"Copying runtime files","index":3,"stages":3}' + ) +}) + +test('simulation log helpers localize pid and per-platform round status', () => { + assert.equal( + formatSimulationPidLog(4321, t), + 'step3.pidLog:{"pid":4321}' + ) + + assert.equal( + formatSimulationRoundLog( + { + platform: 'twitter', + currentRound: 4, + totalRounds: 12, + simulatedHours: 2, + actionsCount: 18, + }, + t + ), + 'step3.roundProgressLog:{"platform":"step3.platformNames.twitter:{}","currentRound":4,"totalRounds":12,"simulatedHours":2,"actionsCount":18}' + ) +}) diff --git a/frontend/tests/simulationTimeline.test.mjs b/frontend/tests/simulationTimeline.test.mjs new file mode 100644 index 00000000..08810ed8 --- /dev/null +++ b/frontend/tests/simulationTimeline.test.mjs @@ -0,0 +1,45 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + describeTimelineAction, + getTimelineActionTypeLabel, + getTimelineAvailableActions, + getTimelinePlatformName, +} from '../src/components/simulationTimeline.js' + +const t = (key, params = {}) => `${key}:${JSON.stringify(params)}` + +test('timeline helpers resolve localized platform names and action lists', () => { + assert.equal(getTimelinePlatformName('twitter', t), 'step3.platformNames.twitter:{}') + + assert.deepEqual(getTimelineAvailableActions('reddit', t), [ + 'step3.availableActionList.post:{}', + 'step3.availableActionList.comment:{}', + 'step3.availableActionList.like:{}', + 'step3.availableActionList.dislike:{}', + 'step3.availableActionList.search:{}', + 'step3.availableActionList.trend:{}', + 'step3.availableActionList.follow:{}', + 'step3.availableActionList.mute:{}', + 'step3.availableActionList.refresh:{}', + 'step3.availableActionList.idle:{}', + ]) +}) + +test('timeline helpers localize badge labels and dynamic action descriptions', () => { + assert.equal(getTimelineActionTypeLabel('UPVOTE_POST', t), 'step3.actionTypes.upvote:{}') + + assert.equal( + describeTimelineAction( + { action_type: 'REPOST', action_args: { original_author_name: 'alice' } }, + t + ), + 'step3.repostedFrom:{"user":"alice"}' + ) + + assert.equal( + describeTimelineAction({ action_type: 'DO_NOTHING', action_args: {} }, t), + 'step3.actionSkipped:{}' + ) +}) diff --git a/frontend/tests/step2Recovery.test.mjs b/frontend/tests/step2Recovery.test.mjs new file mode 100644 index 00000000..e7ce03e3 --- /dev/null +++ b/frontend/tests/step2Recovery.test.mjs @@ -0,0 +1,56 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { getStep2RecoveryState } from '../src/components/step2Recovery.js' + +test('returns null when no saved step 3 state exists', () => { + assert.equal( + getStep2RecoveryState({ + simulation_id: 'sim-idle', + runner_status: 'idle', + current_round: 0, + total_rounds: 0, + }), + null, + ) +}) + +test('maps failed runs to the restart CTA', () => { + assert.deepEqual( + getStep2RecoveryState({ + simulation_id: 'sim-failed', + runner_status: 'failed', + current_round: 3, + total_rounds: 12, + }), + { + noticeKey: 'step2.savedRunFailedNotice', + actionKey: 'step2.restartPreparedRun', + route: { + name: 'SimulationRun', + params: { simulationId: 'sim-failed' }, + query: { replay: '1' }, + }, + }, + ) +}) + +test('maps running runs to the reopen CTA', () => { + assert.deepEqual( + getStep2RecoveryState({ + simulation_id: 'sim-running', + runner_status: 'running', + current_round: 2, + total_rounds: 12, + }), + { + noticeKey: 'step2.savedRunResumeNotice', + actionKey: 'step2.openSavedRun', + route: { + name: 'SimulationRun', + params: { simulationId: 'sim-running' }, + query: { replay: '1' }, + }, + }, + ) +}) diff --git a/frontend/tests/step5Profiles.test.mjs b/frontend/tests/step5Profiles.test.mjs new file mode 100644 index 00000000..3fddf6e3 --- /dev/null +++ b/frontend/tests/step5Profiles.test.mjs @@ -0,0 +1,210 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + buildInterviewRequest, + extractInterviewResponseContent, + formatInterviewFailureMessage, + formatAgentRole, + getEnabledProfilePlatforms, + getPlatformLabel, + getInterviewGuardMessage, + mergeInteractionProfiles, + summarizeInterviewTimeoutBudget, + summarizeInterviewEnvStatus, +} from '../src/components/step5Profiles.js' + +const t = (key, params = {}) => `${key}:${JSON.stringify(params)}` + +test('mergeInteractionProfiles keeps both platforms and annotates ids', () => { + const merged = mergeInteractionProfiles([ + { + platform: 'twitter', + profiles: [{ username: 'tw-user' }], + }, + { + platform: 'reddit', + profiles: [{ username: 'rd-user', agent_id: '4' }], + }, + ]) + + assert.deepEqual( + merged.map(({ username, agent_id: agentId, platform, profileKey }) => ({ + username, + agentId, + platform, + profileKey, + })), + [ + { username: 'rd-user', agentId: 4, platform: 'reddit', profileKey: 'reddit_4' }, + { username: 'tw-user', agentId: 0, platform: 'twitter', profileKey: 'twitter_0' }, + ] + ) +}) + +test('buildInterviewRequest preserves platform-specific targeting', () => { + assert.deepEqual( + buildInterviewRequest( + { agent_id: 7, platform: 'twitter' }, + 'hello' + ), + { + agent_id: 7, + prompt: 'hello', + platform: 'twitter', + } + ) +}) + +test('extractInterviewResponseContent prefers the selected platform result', () => { + const content = extractInterviewResponseContent( + { + result: { + results: { + reddit_3: { response: 'reddit reply' }, + twitter_3: { response: 'twitter reply' }, + }, + }, + }, + { agent_id: 3, platform: 'twitter' } + ) + + assert.equal(content, 'twitter reply') +}) + +test('formatAgentRole includes platform label for mixed-platform lists', () => { + assert.equal( + formatAgentRole( + { profession: 'Analyst', platform: 'reddit' }, + 'Unknown', + t + ), + 'step5.platforms.reddit:{} · Analyst' + ) +}) + +test('getPlatformLabel uses locale translator when available', () => { + assert.equal(getPlatformLabel('twitter', t), 'step5.platforms.twitter:{}') +}) + +test('getEnabledProfilePlatforms keeps only enabled simulation platforms', () => { + assert.deepEqual( + getEnabledProfilePlatforms({ + enable_twitter: true, + enable_reddit: false, + }), + ['twitter'] + ) + + assert.deepEqual( + getEnabledProfilePlatforms({ + enable_twitter: true, + enable_reddit: true, + }), + ['reddit', 'twitter'] + ) +}) + +test('summarizeInterviewEnvStatus reports ready platforms', () => { + assert.equal( + summarizeInterviewEnvStatus( + { + env_alive: true, + reddit_available: true, + twitter_available: false, + }, + t + ), + 'step5.interviewEnvReadyBanner:{"platforms":"step5.platforms.reddit:{}"}' + ) +}) + +test('getInterviewGuardMessage blocks closed environments and unavailable platforms', () => { + assert.equal( + getInterviewGuardMessage( + { + env_alive: false, + reddit_available: false, + twitter_available: false, + }, + [{ platform: 'reddit' }], + t + ), + 'step5.interviewEnvClosedError:{}' + ) + + assert.equal( + getInterviewGuardMessage( + { + env_alive: true, + reddit_available: true, + twitter_available: false, + }, + [{ platform: 'twitter' }], + t + ), + 'step5.interviewPlatformUnavailable:{"platforms":"step5.platforms.twitter:{}"}' + ) +}) + +test('formatInterviewFailureMessage normalizes timeout and env-closed backend errors', () => { + assert.equal( + formatInterviewFailureMessage('模拟环境未运行或已关闭。请确保模拟已完成并进入等待命令模式。', t), + 'step5.interviewEnvClosedError:{}' + ) + + assert.equal( + formatInterviewFailureMessage( + 'The simulation environment is not running or has already closed. Make sure the simulation completed and is in wait-for-commands mode.', + t + ), + 'step5.interviewEnvClosedError:{}' + ) + + assert.equal( + formatInterviewFailureMessage('The environment is already closed', t), + 'step5.interviewEnvClosedError:{}' + ) + + assert.equal( + formatInterviewFailureMessage('等待Interview响应超时: 300s', t), + 'step5.interviewTimeoutError:{"message":"等待Interview响应超时: 300s"}' + ) + + assert.equal( + formatInterviewFailureMessage('Waiting for Interview response timed out: 300s', t), + 'step5.interviewTimeoutError:{"message":"Waiting for Interview response timed out: 300s"}' + ) + + assert.equal( + formatInterviewFailureMessage('等待批量Interview响应超时: 300s', t), + 'step5.interviewTimeoutError:{"message":"等待批量Interview响应超时: 300s"}' + ) + + assert.equal( + formatInterviewFailureMessage('等待全局Interview响应超时: 300s', t), + 'step5.interviewTimeoutError:{"message":"等待全局Interview响应超时: 300s"}' + ) + + assert.equal( + formatInterviewFailureMessage('Timed out while waiting for the batch interview response: 300s', t), + 'step5.interviewTimeoutError:{"message":"Timed out while waiting for the batch interview response: 300s"}' + ) + + assert.equal( + formatInterviewFailureMessage('Timed out while waiting for the global interview response: 300s', t), + 'step5.interviewTimeoutError:{"message":"Timed out while waiting for the global interview response: 300s"}' + ) +}) + +test('summarizeInterviewTimeoutBudget explains single and batch budgets', () => { + assert.equal( + summarizeInterviewTimeoutBudget({ requestTimeoutMs: 300000, selectedCount: 0, t }), + 'step5.interviewTimeoutHintNoSelection:{"singleSeconds":90,"requestSeconds":300}' + ) + + assert.equal( + summarizeInterviewTimeoutBudget({ requestTimeoutMs: 300000, selectedCount: 4, t }), + 'step5.interviewTimeoutHintWithSelection:{"singleSeconds":90,"selectedCount":4,"batchSeconds":150,"requestSeconds":300}' + ) +}) diff --git a/frontend/tests/step5Recovery.test.mjs b/frontend/tests/step5Recovery.test.mjs new file mode 100644 index 00000000..d124aecc --- /dev/null +++ b/frontend/tests/step5Recovery.test.mjs @@ -0,0 +1,63 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { getStep5RecoveryState } from '../src/components/step5Recovery.js' + +test('returns null when the interview environment is still alive', () => { + assert.equal( + getStep5RecoveryState({ + simulation: { + simulation_id: 'sim-running', + runner_status: 'running', + current_round: 2, + total_rounds: 12, + }, + envStatus: { + env_alive: true, + }, + }), + null, + ) +}) + +test('returns the Step 3 replay route when offline state is recoverable', () => { + assert.deepEqual( + getStep5RecoveryState({ + simulation: { + simulation_id: 'sim-failed', + runner_status: 'failed', + current_round: 3, + total_rounds: 12, + }, + envStatus: { + env_alive: false, + }, + }), + { + noticeKey: 'step2.savedRunFailedNotice', + actionKey: 'step2.restartPreparedRun', + route: { + name: 'SimulationRun', + params: { simulationId: 'sim-failed' }, + query: { replay: '1' }, + }, + }, + ) +}) + +test('returns null when no replayable simulation state exists', () => { + assert.equal( + getStep5RecoveryState({ + simulation: { + simulation_id: 'sim-idle', + runner_status: 'idle', + current_round: 0, + total_rounds: 0, + }, + envStatus: { + env_alive: false, + }, + }), + null, + ) +}) diff --git a/frontend/tests/timeout.test.mjs b/frontend/tests/timeout.test.mjs new file mode 100644 index 00000000..52c6923e --- /dev/null +++ b/frontend/tests/timeout.test.mjs @@ -0,0 +1,28 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { deriveInterviewTimeoutSeconds, resolveTimeoutMs } from '../src/api/timeout.js' + +test('resolveTimeoutMs falls back to the default when the env value is invalid', () => { + assert.equal(resolveTimeoutMs('not-a-number'), 300000) + assert.equal(resolveTimeoutMs('450000'), 450000) +}) + +test('deriveInterviewTimeoutSeconds leaves headroom under the client timeout', () => { + assert.equal( + deriveInterviewTimeoutSeconds({ requestTimeoutMs: 300000, interviewsCount: 1 }), + 90 + ) + + assert.equal( + deriveInterviewTimeoutSeconds({ requestTimeoutMs: 300000, interviewsCount: 10 }), + 270 + ) +}) + +test('deriveInterviewTimeoutSeconds clamps oversized interview batches to the request budget', () => { + assert.equal( + deriveInterviewTimeoutSeconds({ requestTimeoutMs: 120000, interviewsCount: 20 }), + 115 + ) +}) diff --git a/frontend/tests/verificationBundle.test.mjs b/frontend/tests/verificationBundle.test.mjs new file mode 100644 index 00000000..ccbe6ae3 --- /dev/null +++ b/frontend/tests/verificationBundle.test.mjs @@ -0,0 +1,35 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { buildVerificationReferenceBundle } from '../src/components/verificationBundle.js' + +test('buildVerificationReferenceBundle includes stable report and simulation references', () => { + assert.equal( + buildVerificationReferenceBundle({ + simulationId: 'sim_123', + reportId: 'report_456', + timestamp: '2026-03-12T03:50:00Z', + }), + [ + 'MiroFish verification reference', + 'simulation_id: sim_123', + 'report_id: report_456', + 'timestamp: 2026-03-12T03:50:00Z', + 'report_markdown_path: backend/uploads/reports/report_456/full_report.md', + ].join('\n') + ) +}) + +test('buildVerificationReferenceBundle omits blank optional fields', () => { + assert.equal( + buildVerificationReferenceBundle({ + simulationId: ' sim_123 ', + reportId: ' ', + timestamp: '', + }), + [ + 'MiroFish verification reference', + 'simulation_id: sim_123', + ].join('\n') + ) +}) diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 7cec1a71..0c89d7ee 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,17 +1,22 @@ -import { defineConfig } from 'vite' +import { defineConfig, loadEnv } from 'vite' import vue from '@vitejs/plugin-vue' // https://vite.dev/config/ -export default defineConfig({ - plugins: [vue()], - server: { - port: 3000, - open: true, - proxy: { - '/api': { - target: 'http://localhost:5001', - changeOrigin: true, - secure: false +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), '') + const apiBaseUrl = env.VITE_API_BASE_URL || 'http://localhost:5001' + + return { + plugins: [vue()], + server: { + port: 3000, + open: true, + proxy: { + '/api': { + target: apiBaseUrl, + changeOrigin: true, + secure: false + } } } } diff --git a/package.json b/package.json index 63ace21a..cacaf0de 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,22 @@ "scripts": { "setup": "npm install && cd frontend && npm install", "setup:backend": "cd backend && uv sync", - "setup:all": "npm run setup && npm run setup:backend", + "setup:core": "npm run setup && npm run setup:backend", + "setup:backend:simulation": "python3 ./scripts/setup_backend_simulation.py", + "setup:all": "npm run setup:core", + "sync:upstream": "bash ./scripts/refresh_upstream_snapshots.sh", "dev": "concurrently --kill-others -n \"backend,frontend\" -c \"green,cyan\" \"npm run backend\" \"npm run frontend\"", "backend": "cd backend && uv run python run.py", + "backend:local": "npm run check:backend-config && npm run backend", + "check:backend-config": "cd backend && uv run python scripts/print_config_status.py --locale en", "frontend": "cd frontend && npm run dev", - "build": "cd frontend && npm run build" + "build": "cd frontend && npm run build", + "test:backend:lite": "bash ./scripts/test_backend_lite.sh", + "test:frontend": "npm --prefix frontend test", + "build:frontend": "npm --prefix frontend run build", + "validate": "bash ./scripts/validate_repo.sh", + "validate:fast": "bash ./scripts/validate_repo.sh --skip-frontend-build", + "hooks:install": "bash ./scripts/install_git_hooks.sh" }, "devDependencies": { "concurrently": "^9.1.2" diff --git a/scripts/install_git_hooks.sh b/scripts/install_git_hooks.sh new file mode 100755 index 00000000..38eae7f2 --- /dev/null +++ b/scripts/install_git_hooks.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +HOOK_DIR="${ROOT_DIR}/.githooks" + +if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then + cat <<'EOF' +Usage: bash ./scripts/install_git_hooks.sh + +Configures git core.hooksPath to use the repo-local .githooks directory. +This is opt-in and can be reverted with: + git config --unset core.hooksPath +EOF + exit 0 +fi + +if ! git -C "$ROOT_DIR" rev-parse --git-dir >/dev/null 2>&1; then + echo "This installer must be run inside a git repository." >&2 + exit 1 +fi + +chmod +x "${HOOK_DIR}/pre-commit" "${HOOK_DIR}/pre-push" +git -C "$ROOT_DIR" config core.hooksPath .githooks + +echo "Installed repo-local git hooks from .githooks" +echo "pre-commit: backend lite tests + frontend tests" +echo "pre-push: full validation (backend lite tests + frontend tests + frontend build)" diff --git a/scripts/refresh_upstream_snapshots.sh b/scripts/refresh_upstream_snapshots.sh new file mode 100755 index 00000000..418306d8 --- /dev/null +++ b/scripts/refresh_upstream_snapshots.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REPO="666ghj/MiroFish" +FORK_REMOTE="${FORK_REMOTE:-origin}" +MIRROR_ISSUES_REPO="${MIRROR_ISSUES_REPO:-ivanzud/MiroFish}" +COVERAGE_MAP="${COVERAGE_MAP:-docs/upstream-coverage.json}" +LOCK_WAIT_SECONDS="${LOCK_WAIT_SECONDS:-5}" +EXTRA_ARGS=() + +usage() { + cat <<'EOF' +Usage: refresh_upstream_snapshots.sh [owner/repo] [options] + +Refresh both the open-only and full upstream GitHub snapshots using repo defaults. + +Options: + --repo <owner/repo> Override the upstream repository to inspect. + --fork-remote <name> Override the git remote used for PR mirror checks/pushes. + --mirror-issues-repo <repo> Override the fork repo used for mirrored issue lookup. + --timeout <seconds> Per-request timeout passed to sync_upstream_github.py. + --max-workers <count> Limit concurrent hydration workers. + --stale-cache-hours <hours> Allow reuse of recent snapshots on GitHub rate limits. + --lock-wait-seconds <seconds> Wait this long for the upstream-sync lock. + --force-refresh, --no-cache Disable stale-cache fallback for this run. + --help Show this message. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + usage + exit 0 + ;; + --repo) + if [[ $# -lt 2 ]]; then + echo "error: --repo requires an owner/repo value" >&2 + exit 1 + fi + REPO="$2" + shift 2 + ;; + --repo=*) + REPO="${1#*=}" + shift + ;; + --fork-remote) + if [[ $# -lt 2 ]]; then + echo "error: --fork-remote requires a remote name" >&2 + exit 1 + fi + FORK_REMOTE="$2" + shift 2 + ;; + --fork-remote=*) + FORK_REMOTE="${1#*=}" + shift + ;; + --mirror-issues-repo) + if [[ $# -lt 2 ]]; then + echo "error: --mirror-issues-repo requires an owner/repo value" >&2 + exit 1 + fi + MIRROR_ISSUES_REPO="$2" + shift 2 + ;; + --mirror-issues-repo=*) + MIRROR_ISSUES_REPO="${1#*=}" + shift + ;; + --force-refresh|--no-cache) + EXTRA_ARGS+=(--stale-cache-hours -1) + shift + ;; + --timeout|--max-workers|--stale-cache-hours|--lock-wait-seconds) + if [[ $# -lt 2 ]]; then + echo "error: $1 requires a value" >&2 + exit 1 + fi + EXTRA_ARGS+=("$1" "$2") + shift 2 + ;; + --timeout=*|--max-workers=*|--stale-cache-hours=*|--lock-wait-seconds=*) + EXTRA_ARGS+=("$1") + shift + ;; + -*) + echo "error: unsupported option '$1'" >&2 + usage >&2 + exit 1 + ;; + *) + REPO="$1" + shift + ;; + esac +done + +cd "${ROOT_DIR}" + +run_sync() { + local state="$1" + local output="$2" + local summary="$3" + + python3 "${ROOT_DIR}/scripts/sync_upstream_github.py" \ + --repo "${REPO}" \ + --state "${state}" \ + --output "${output}" \ + --summary "${summary}" \ + --fork-remote "${FORK_REMOTE}" \ + --mirror-issues-repo "${MIRROR_ISSUES_REPO}" \ + --coverage-map "${COVERAGE_MAP}" \ + --lock-wait-seconds "${LOCK_WAIT_SECONDS}" \ + "${EXTRA_ARGS[@]}" +} + +run_sync open docs/upstream-open-state.json docs/upstream-open-summary.md +run_sync all docs/upstream-all-state.json docs/upstream-all-summary.md diff --git a/scripts/setup_backend_simulation.py b/scripts/setup_backend_simulation.py new file mode 100644 index 00000000..c94df7a4 --- /dev/null +++ b/scripts/setup_backend_simulation.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Install optional backend simulation dependencies with environment checks.""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + + +SUPPORTED_MAX_PYTHON = (3, 12) + + +def python_version_label(version_info: tuple[int, int]) -> str: + """Return a compact major.minor version string.""" + return f"{version_info[0]}.{version_info[1]}" + + +def validate_environment( + version_info: tuple[int, int] | None = None, + rustc_available: bool | None = None, +) -> str | None: + """Return an actionable validation error, or None when installation can proceed.""" + version_info = version_info or (sys.version_info.major, sys.version_info.minor) + + if version_info <= SUPPORTED_MAX_PYTHON: + return None + + if rustc_available is None: + rustc_available = shutil.which("rustc") is not None + + if rustc_available: + return None + + return ( + "Optional simulation dependencies are not supported out-of-the-box on " + f"Python {python_version_label(version_info)} without Rust. " + "The current camel-ai -> tiktoken dependency chain falls back to a source " + "build on Python 3.13+. Use Python 3.11/3.12 for Step 3 / Step 5 simulation " + "setup, or install a Rust toolchain and rerun this command." + ) + + +def main() -> int: + error = validate_environment() + if error: + print(error, file=sys.stderr) + return 1 + + backend_dir = Path(__file__).resolve().parent.parent / "backend" + result = subprocess.run( + ["uv", "sync", "--extra", "simulation"], + cwd=backend_dir, + check=False, + ) + return result.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/sync_upstream_github.py b/scripts/sync_upstream_github.py new file mode 100644 index 00000000..344b280b --- /dev/null +++ b/scripts/sync_upstream_github.py @@ -0,0 +1,1322 @@ +#!/usr/bin/env python3 +"""Fetch upstream GitHub issues and pull requests into local summaries.""" + +from __future__ import annotations + +import argparse +import contextlib +import concurrent.futures +import errno +import fcntl +import json +import os +import re +import shutil +import subprocess +import sys +import time +import urllib.parse +import urllib.request +from urllib.error import HTTPError, URLError +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +BODY_EXCERPT_LIMIT = 400 +COMMENT_EXCERPT_LIMIT = 240 +RECENT_COMMENT_LIMIT = 3 +GH_API_MAX_ATTEMPTS = 3 +DEFAULT_API_TIMEOUT = int(os.environ.get("MIROFISH_GITHUB_SYNC_TIMEOUT", "30")) +REQUEST_TIMEOUT = DEFAULT_API_TIMEOUT +DEFAULT_STALE_CACHE_HOURS = int(os.environ.get("MIROFISH_GITHUB_SYNC_STALE_HOURS", "24")) +DEFAULT_MAX_WORKERS = int(os.environ.get("MIROFISH_GITHUB_SYNC_MAX_WORKERS", "8")) +DEFAULT_REPO = "666ghj/MiroFish" +UPSTREAM_ISSUE_REPO_MARKER = "mirofish-upstream-repo:" +UPSTREAM_ISSUE_NUMBER_MARKER = "mirofish-upstream-issue:" +GH_CLI_USABLE: bool | None = None +GH_CLI_DISABLED_REASON: str | None = None + + +def has_github_token() -> bool: + return bool(os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")) + + +def can_use_gh_cli() -> bool: + global GH_CLI_USABLE + + if GH_CLI_DISABLED_REASON: + return False + if GH_CLI_USABLE is not None: + return GH_CLI_USABLE + + if shutil.which("gh") is None: + GH_CLI_USABLE = False + return False + + try: + result = subprocess.run( + ["gh", "auth", "status"], + check=False, + capture_output=True, + text=True, + ) + except OSError: + GH_CLI_USABLE = False + return False + + GH_CLI_USABLE = result.returncode == 0 + return GH_CLI_USABLE + + +def disable_gh_cli(reason: str) -> None: + global GH_CLI_DISABLED_REASON, GH_CLI_USABLE + GH_CLI_DISABLED_REASON = reason + GH_CLI_USABLE = False + + +def _is_retryable_gh_error(message: str) -> bool: + lowered = message.lower() + markers = ( + "timeout", + "timed out", + "connection reset", + "tls", + "eof", + "502", + "503", + "504", + "secondary rate limit", + ) + return any(marker in lowered for marker in markers) + + +def _is_retryable_http_status(code: int) -> bool: + return code in {500, 502, 503, 504} + + +def fetch_json_via_gh(url: str) -> object: + parsed = urllib.parse.urlparse(url) + endpoint = parsed.path + if parsed.query: + endpoint = f"{endpoint}?{parsed.query}" + + last_error: RuntimeError | None = None + for attempt in range(1, GH_API_MAX_ATTEMPTS + 1): + try: + result = subprocess.run( + ["gh", "api", endpoint], + check=True, + capture_output=True, + text=True, + timeout=REQUEST_TIMEOUT, + ) + return json.loads(result.stdout) + except subprocess.TimeoutExpired as exc: + last_error = RuntimeError( + f"gh api timed out for {endpoint} after {REQUEST_TIMEOUT}s" + ) + if attempt >= GH_API_MAX_ATTEMPTS: + raise last_error from exc + time.sleep(attempt) + except subprocess.CalledProcessError as exc: + details = (exc.stderr or exc.stdout or "").strip() or f"exit status {exc.returncode}" + last_error = RuntimeError(f"gh api failed for {endpoint}: {details}") + if attempt >= GH_API_MAX_ATTEMPTS or not _is_retryable_gh_error(details): + raise last_error from exc + time.sleep(attempt) + + assert last_error is not None + raise last_error + + +def _fetch_json_via_http(url: str) -> object: + headers = {"User-Agent": "mirofish-upstream-sync"} + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + if token: + headers["Authorization"] = f"Bearer {token}" + + request = urllib.request.Request(url, headers=headers) + last_error: RuntimeError | None = None + for attempt in range(1, GH_API_MAX_ATTEMPTS + 1): + try: + with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response: + return json.load(response) + except TimeoutError as exc: + last_error = RuntimeError( + f"GitHub API request timed out after {REQUEST_TIMEOUT}s for {url}" + ) + if attempt >= GH_API_MAX_ATTEMPTS: + raise last_error from exc + time.sleep(attempt) + except HTTPError as exc: + if exc.code == 403 and "rate limit" in str(exc).lower(): + raise RuntimeError( + "GitHub API rate limit exceeded. Set GITHUB_TOKEN or GH_TOKEN, or log into gh before running sync." + ) from exc + + last_error = RuntimeError(f"GitHub API request failed for {url}: HTTP {exc.code}") + if attempt >= GH_API_MAX_ATTEMPTS or not _is_retryable_http_status(exc.code): + raise last_error from exc + time.sleep(attempt) + except URLError as exc: + details = str(getattr(exc, "reason", exc)).strip() or str(exc) + last_error = RuntimeError(f"GitHub API request failed for {url}: {details}") + if attempt >= GH_API_MAX_ATTEMPTS or not _is_retryable_gh_error(details): + raise last_error from exc + time.sleep(attempt) + + assert last_error is not None + raise last_error + + +def fetch_json(url: str) -> object: + if not has_github_token() and can_use_gh_cli(): + try: + return fetch_json_via_gh(url) + except RuntimeError as exc: + if "rate limit" in str(exc).lower(): + disable_gh_cli("GitHub CLI rate limited") + print( + f"warning: {exc}; falling back to direct GitHub HTTP request", + file=sys.stderr, + ) + + return _fetch_json_via_http(url) + + +def github_api(path: str, params: dict[str, object]) -> object: + url = f"https://api.github.com{path}" + if params: + query = urllib.parse.urlencode(params) + url = f"{url}?{query}" + return fetch_json(url) + + +def github_api_paginated(path: str, params: dict[str, object], limit: int) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + page = 1 + per_page = min(limit, 100) + + while len(items) < limit: + payload = github_api(path, {**params, "per_page": per_page, "page": page}) + if not isinstance(payload, list): + raise ValueError(f"Expected list payload from GitHub API for {path}, got {type(payload)!r}") + if not payload: + break + + items.extend(payload) + if len(payload) < per_page: + break + + page += 1 + + return items[:limit] + + +def normalize_excerpt(text: str | None, limit: int) -> str: + if not text: + return "" + + collapsed = re.sub(r"\s+", " ", text).strip() + if len(collapsed) <= limit: + return collapsed + + return collapsed[: max(0, limit - 1)].rstrip() + "…" + + +def fetch_recent_comments(comments_url: str | None, limit: int = RECENT_COMMENT_LIMIT) -> list[dict[str, Any]]: + if not comments_url or limit <= 0: + return [] + + payload = fetch_json( + f"{comments_url}?{urllib.parse.urlencode({'per_page': limit, 'sort': 'updated', 'direction': 'desc'})}" + ) + if not isinstance(payload, list): + raise ValueError(f"Expected list payload when fetching comments from {comments_url}, got {type(payload)!r}") + + comments: list[dict[str, Any]] = [] + for item in payload[:limit]: + comments.append( + { + "author": item.get("user", {}).get("login"), + "created_at": item.get("created_at"), + "updated_at": item.get("updated_at"), + "url": item.get("html_url"), + "body_excerpt": normalize_excerpt(item.get("body"), COMMENT_EXCERPT_LIMIT), + } + ) + return comments + + +def hydrate_pull_requests( + owner: str, + name: str, + pull_requests: list[dict[str, Any]], + max_workers: int, +) -> list[dict[str, Any]]: + def hydrate_one(pull_request: dict[str, Any]) -> dict[str, Any]: + number = pull_request.get("number") + if number is None: + raise ValueError("Pull request payload missing number") + details = github_api(f"/repos/{owner}/{name}/pulls/{number}", {}) + if not isinstance(details, dict): + raise ValueError(f"Expected pull request details dict for #{number}, got {type(details)!r}") + return details + + return parallel_ordered_map( + pull_requests, + hydrate_one, + max_workers=max_workers, + ) + + +def parallel_ordered_map( + items: list[Any], + func, + *, + max_workers: int, +) -> list[Any]: + if not items: + return [] + + resolved_max_workers = max(1, min(max_workers, len(items))) + if resolved_max_workers == 1: + return [func(item) for item in items] + + results: list[Any] = [None] * len(items) + with concurrent.futures.ThreadPoolExecutor(max_workers=resolved_max_workers) as executor: + future_to_index = { + executor.submit(func, item): index for index, item in enumerate(items) + } + try: + for future in concurrent.futures.as_completed(future_to_index): + index = future_to_index[future] + results[index] = future.result() + except Exception: + for future in future_to_index: + future.cancel() + raise + return results + + +def list_mirrored_pull_request_numbers(remote: str) -> set[int]: + try: + result = subprocess.run( + [ + "git", + "for-each-ref", + "--format=%(refname:short)", + f"refs/remotes/{remote}/mirror/upstream-pr-*", + "refs/heads/mirror/upstream-pr-*", + ], + check=True, + capture_output=True, + text=True, + ) + except (OSError, subprocess.CalledProcessError) as exc: + raise RuntimeError(f"Unable to inspect mirrored pull request refs for remote {remote!r}") from exc + + mirrored: set[int] = set() + for line in result.stdout.splitlines(): + match = re.search(r"mirror/upstream-pr-(\d+)$", line.strip()) + if match: + mirrored.add(int(match.group(1))) + return mirrored + + +def run_git_command(args: list[str]) -> str: + try: + result = subprocess.run( + args, + check=True, + capture_output=True, + text=True, + timeout=REQUEST_TIMEOUT, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise RuntimeError(f"{' '.join(args)} failed") from exc + except subprocess.CalledProcessError as exc: + details = (exc.stderr or exc.stdout or "").strip() or f"exit status {exc.returncode}" + raise RuntimeError(f"{' '.join(args)} failed: {details}") from exc + + return result.stdout + + +def mirror_pull_request_refs( + upstream_repo: str, + fork_remote: str, + pull_requests: list[dict[str, Any]], + mirrored_pr_numbers: set[int] | None = None, +) -> set[int]: + mirrored = set(mirrored_pr_numbers or set()) + upstream_url = f"https://github.com/{upstream_repo}.git" + + for pr in pull_requests: + number = int(pr["number"]) + if number in mirrored: + continue + + cache_ref = f"refs/remotes/upstream-sync/pr-{number}" + mirror_ref = f"refs/heads/mirror/upstream-pr-{number}" + try: + run_git_command( + [ + "git", + "fetch", + "--force", + upstream_url, + f"pull/{number}/head:{cache_ref}", + ] + ) + run_git_command( + [ + "git", + "push", + fork_remote, + f"{cache_ref}:{mirror_ref}", + ] + ) + mirrored.add(number) + except RuntimeError as exc: + print( + f"warning: unable to mirror upstream PR #{number} into {fork_remote}: {exc}", + file=sys.stderr, + ) + + return mirrored + + +def load_local_coverage_entries(path: Path | None, key: str) -> dict[int, dict[str, object]]: + if path is None or not path.exists(): + return {} + + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + + entries = payload.get(key, []) if isinstance(payload, dict) else [] + if not isinstance(entries, list): + return {} + + coverage_map: dict[int, dict[str, object]] = {} + for entry in entries: + if not isinstance(entry, dict): + continue + number = entry.get("number") + if isinstance(number, int): + coverage_map[number] = entry + return coverage_map + + +def load_local_issue_coverage(path: Path | None) -> dict[int, dict[str, object]]: + return load_local_coverage_entries(path, "issues") + + +def load_local_pr_coverage(path: Path | None) -> dict[int, dict[str, object]]: + return load_local_coverage_entries(path, "pull_requests") + + +def attach_local_coverage_fields( + item: dict[str, object], + local_coverage: dict[str, object] | None, +) -> dict[str, object]: + enriched = dict(item) + if local_coverage: + triage_status = str(local_coverage.get("status") or "covered") + triage_summary = str(local_coverage.get("summary") or "covered locally on this branch") + enriched["local_coverage"] = local_coverage + enriched["local_status"] = triage_status + enriched["local_summary"] = triage_summary + else: + triage_status = "untracked" + triage_summary = str(item.get("body_excerpt") or "") + + # Promote stable top-level fields for downstream machine-readable consumers. + enriched["triage_status"] = triage_status + enriched["summary"] = triage_summary + # Backward-compatible aliases retained for older downstream consumers. + enriched["coverage_status"] = triage_status + enriched["coverage_summary"] = triage_summary + return enriched + + +def attach_pr_review_fields( + item: dict[str, object], + local_coverage: dict[str, object] | None, +) -> dict[str, object]: + enriched = dict(item) + if local_coverage: + enriched["local_review"] = { + "status": str(local_coverage.get("status") or "covered"), + "summary": str(local_coverage.get("summary") or "covered locally on this branch"), + "local_refs": list(local_coverage.get("local_refs") or []), + "validation": list(local_coverage.get("validation") or []), + "notes": local_coverage.get("notes"), + } + else: + enriched["local_review"] = { + "status": "unreviewed", + "summary": str(item.get("body_excerpt") or ""), + "local_refs": [], + "validation": [], + "notes": None, + } + return enriched + + +def compact_issue( + issue: dict[str, object], + coverage_map: dict[int, dict[str, object]] | None = None, +) -> dict[str, object]: + comment_count = int(issue.get("comments") or 0) + compacted = { + "number": issue["number"], + "title": issue["title"], + "url": issue["html_url"], + "state": issue["state"], + "created_at": issue["created_at"], + "updated_at": issue["updated_at"], + "closed_at": issue.get("closed_at"), + "labels": [label["name"] for label in issue.get("labels", [])], + "author": issue.get("user", {}).get("login"), + "body_excerpt": normalize_excerpt(issue.get("body"), BODY_EXCERPT_LIMIT), + "comment_count": comment_count, + "recent_comments": fetch_recent_comments(issue.get("comments_url")) if comment_count else [], + } + local_coverage = (coverage_map or {}).get(int(issue["number"])) + return attach_local_coverage_fields(compacted, local_coverage) + + +def compact_issues( + issue_items: list[dict[str, object]], + max_workers: int, + coverage_map: dict[int, dict[str, object]] | None = None, +) -> list[dict[str, object]]: + return parallel_ordered_map( + issue_items, + lambda item: compact_issue(item, coverage_map), + max_workers=max_workers, + ) + + +def build_mirror_issue_title(issue: dict[str, object]) -> str: + return f"[Upstream #{issue['number']}] {issue['title']}" + + +def build_mirror_issue_body(upstream_repo: str, issue: dict[str, object]) -> str: + labels = ", ".join(issue.get("labels", [])) or "none" + lines = [ + f"<!-- {UPSTREAM_ISSUE_REPO_MARKER}{upstream_repo} -->", + f"<!-- {UPSTREAM_ISSUE_NUMBER_MARKER}{issue['number']} -->", + "# Upstream Issue Mirror", + "", + f"- Upstream issue: {issue['url']}", + f"- Upstream repository: `{upstream_repo}`", + f"- State: `{issue['state']}`", + f"- Author: `{issue.get('author') or 'unknown'}`", + f"- Labels: `{labels}`", + f"- Last updated: `{issue.get('updated_at') or 'unknown'}`", + "", + "## Summary", + "", + issue.get("body_excerpt") or "_No upstream body excerpt available._", + ] + local_coverage = issue.get("local_coverage") or {} + if local_coverage: + lines.extend( + [ + "", + "## Local Coverage", + "", + f"- Status: `{local_coverage.get('status') or 'tracked'}`", + f"- Summary: {local_coverage.get('summary') or 'Tracked locally on this branch.'}", + ] + ) + local_refs = local_coverage.get("local_refs") or [] + if local_refs: + lines.append(f"- Local refs: {', '.join(f'`{ref}`' for ref in local_refs)}") + notes = local_coverage.get("notes") + if notes: + lines.append(f"- Notes: {notes}") + + recent_comments = issue.get("recent_comments") or [] + if recent_comments: + lines.extend(["", "## Recent Upstream Comments", ""]) + for comment in recent_comments: + author = comment.get("author") or "unknown" + created_at = comment.get("created_at") or "unknown" + excerpt = comment.get("body_excerpt") or "(no comment body)" + lines.append(f"- `{author}` at `{created_at}`: {excerpt}") + + return "\n".join(lines) + "\n" + + +def extract_upstream_issue_marker(body: str | None) -> tuple[str, int] | None: + if not body: + return None + + repo_match = re.search(rf"<!--\s*{re.escape(UPSTREAM_ISSUE_REPO_MARKER)}(.*?)\s*-->", body) + issue_match = re.search(rf"<!--\s*{re.escape(UPSTREAM_ISSUE_NUMBER_MARKER)}(\d+)\s*-->", body) + if not repo_match or not issue_match: + return None + + try: + return repo_match.group(1).strip(), int(issue_match.group(1)) + except ValueError: + return None + + +def run_gh_command(args: list[str], input_text: str | None = None) -> str: + try: + result = subprocess.run( + ["gh", *args], + check=True, + capture_output=True, + input=input_text, + text=True, + timeout=REQUEST_TIMEOUT, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise RuntimeError(f"gh {' '.join(args)} failed") from exc + except subprocess.CalledProcessError as exc: + details = (exc.stderr or exc.stdout or "").strip() or f"exit status {exc.returncode}" + raise RuntimeError(f"gh {' '.join(args)} failed: {details}") from exc + + return result.stdout + + +def normalize_issue_state(state: object) -> str: + lowered = str(state or "").strip().lower() + if lowered in {"closed", "open"}: + return lowered + return "open" + + +def extract_issue_number_from_gh_output(output: str | None) -> int | None: + if not output: + return None + + match = re.search(r"/issues/(\d+)(?:\s|$)", output.strip()) + if not match: + return None + + return int(match.group(1)) + + +def list_fork_issue_mirrors(fork_repo: str, upstream_repo: str) -> dict[int, dict[str, object]]: + payload = run_gh_command( + [ + "issue", + "list", + "-R", + fork_repo, + "--state", + "all", + "--limit", + "200", + "--json", + "number,title,body,url,state", + ] + ) + issues = json.loads(payload or "[]") + mirrors: dict[int, dict[str, object]] = {} + for issue in issues: + if not isinstance(issue, dict): + continue + marker = extract_upstream_issue_marker(issue.get("body")) + if marker is None: + continue + marker_repo, marker_number = marker + if marker_repo != upstream_repo: + continue + mirrors[marker_number] = issue + return mirrors + + +def sync_fork_issue_mirror( + fork_repo: str, + upstream_repo: str, + issue: dict[str, object], + current: dict[str, object] | None, +) -> None: + desired_title = build_mirror_issue_title(issue) + desired_body = build_mirror_issue_body(upstream_repo, issue) + desired_state = normalize_issue_state(issue.get("state")) + + if current is None: + created_output = run_gh_command( + [ + "issue", + "create", + "-R", + fork_repo, + "--title", + desired_title, + "--body-file", + "-", + ], + input_text=desired_body, + ) + if desired_state == "closed": + created_number = extract_issue_number_from_gh_output(created_output) + if created_number is None: + refreshed = list_fork_issue_mirrors(fork_repo, upstream_repo) + created_issue = refreshed.get(int(issue["number"])) + if created_issue is None: + raise RuntimeError( + f"Created fork issue mirror for upstream #{issue['number']} but could not resolve its issue number" + ) + created_number = int(created_issue["number"]) + run_gh_command( + [ + "issue", + "close", + "-R", + fork_repo, + str(created_number), + "--reason", + "completed", + ] + ) + return + + current_number = str(current["number"]) + current_state = normalize_issue_state(current.get("state")) + if current.get("title") != desired_title or (current.get("body") or "").rstrip() != desired_body.rstrip(): + run_gh_command( + [ + "issue", + "edit", + "-R", + fork_repo, + current_number, + "--title", + desired_title, + "--body-file", + "-", + ], + input_text=desired_body, + ) + + if current_state == desired_state: + return + + if desired_state == "open": + run_gh_command( + [ + "issue", + "reopen", + "-R", + fork_repo, + current_number, + ] + ) + return + + run_gh_command( + [ + "issue", + "close", + "-R", + fork_repo, + current_number, + "--reason", + "completed", + ] + ) + + +def attach_fork_issue_mirror_metadata( + issues: list[dict[str, object]], + fork_issue_map: dict[int, dict[str, object]], +) -> list[dict[str, object]]: + attached: list[dict[str, object]] = [] + for issue in issues: + enriched = dict(issue) + mirrored = fork_issue_map.get(int(issue["number"])) + if mirrored: + enriched["fork_issue_mirrored"] = True + enriched["fork_issue_number"] = mirrored.get("number") + enriched["fork_issue_url"] = mirrored.get("url") + else: + enriched["fork_issue_mirrored"] = False + enriched["fork_issue_number"] = None + enriched["fork_issue_url"] = None + attached.append(enriched) + return attached + + +def mirror_issues_to_fork( + fork_repo: str, + upstream_repo: str, + issues: list[dict[str, object]], +) -> list[dict[str, object]]: + existing = list_fork_issue_mirrors(fork_repo, upstream_repo) + for issue in issues: + sync_fork_issue_mirror( + fork_repo, + upstream_repo, + issue, + existing.get(int(issue["number"])), + ) + + return attach_fork_issue_mirror_metadata( + issues, + list_fork_issue_mirrors(fork_repo, upstream_repo), + ) + + +def compact_pr( + pr: dict[str, object], + mirrored_pr_numbers: set[int] | None = None, + fork_remote: str | None = None, + coverage_map: dict[int, dict[str, object]] | None = None, +) -> dict[str, object]: + number = int(pr["number"]) + head = pr.get("head") or {} + base = pr.get("base") or {} + head_repo = head.get("repo") or {} + base_repo = base.get("repo") or {} + fork_mirrored = False + fork_mirror_ref = None + if mirrored_pr_numbers is not None and fork_remote is not None: + fork_mirrored = number in mirrored_pr_numbers + fork_mirror_ref = f"{fork_remote}/mirror/upstream-pr-{number}" + comment_count = int(pr.get("comments") or 0) + review_comment_count = int(pr.get("review_comments") or 0) + + compacted = { + "number": number, + "title": pr["title"], + "url": pr["html_url"], + "state": pr["state"], + "created_at": pr["created_at"], + "updated_at": pr["updated_at"], + "closed_at": pr.get("closed_at"), + "merged_at": pr.get("merged_at"), + "head": head.get("ref"), + "head_ref_name": head.get("ref"), + "head_sha": head.get("sha"), + "head_repo": head_repo.get("full_name"), + "head_clone_url": head_repo.get("clone_url"), + "base": base.get("ref"), + "base_ref_name": base.get("ref"), + "base_repo": base_repo.get("full_name"), + "draft": pr.get("draft", False), + "mergeable_state": pr.get("mergeable_state"), + "labels": [label["name"] for label in pr.get("labels", [])], + "author": pr.get("user", {}).get("login"), + "body_excerpt": normalize_excerpt(pr.get("body"), BODY_EXCERPT_LIMIT), + "comment_count": comment_count, + "review_comment_count": review_comment_count, + "recent_comments": fetch_recent_comments(pr.get("comments_url")) if comment_count else [], + "fork_mirrored": fork_mirrored, + "mirrored_to_origin": fork_mirrored, + "fork_mirror_ref": fork_mirror_ref, + "mirror_ref": fork_mirror_ref, + } + local_coverage = (coverage_map or {}).get(number) + return attach_pr_review_fields( + attach_local_coverage_fields(compacted, local_coverage), + local_coverage, + ) + + +def compact_pull_requests( + pr_items: list[dict[str, object]], + mirrored_pr_numbers: set[int] | None, + fork_remote: str | None, + max_workers: int, + coverage_map: dict[int, dict[str, object]] | None = None, +) -> list[dict[str, object]]: + return parallel_ordered_map( + pr_items, + lambda item: compact_pr(item, mirrored_pr_numbers, fork_remote, coverage_map), + max_workers=max_workers, + ) + + +def summarize_counts(items: list[dict[str, object]]) -> dict[str, int]: + counts: dict[str, int] = {} + for item in items: + state = str(item.get("state", "unknown")) + counts[state] = counts.get(state, 0) + 1 + return counts + + +def write_summary( + path: Path, + repo: str, + state: str, + issues: list[dict[str, object]], + prs: list[dict[str, object]], + fork_remote: str | None = None, + mirror_issues_repo: str | None = None, + captured_at: str | None = None, + coverage_map_path: str | None = None, +) -> None: + issue_counts = summarize_counts(issues) + pr_counts = summarize_counts(prs) + mirrored_count = sum(1 for pr in prs if pr.get("fork_mirrored")) + mirrored_issue_count = sum(1 for issue in issues if issue.get("fork_issue_mirrored")) + lines = [ + "# Upstream Triage Snapshot", + "", + f"- Repository: `{repo}`", + f"- State filter: `{state}`", + f"- Captured: `{captured_at or datetime.now(timezone.utc).isoformat()}`", + f"- Issues: `{len(issues)}` total (`open={issue_counts.get('open', 0)}`, `closed={issue_counts.get('closed', 0)}`)", + f"- Pull requests: `{len(prs)}` total (`open={pr_counts.get('open', 0)}`, `closed={pr_counts.get('closed', 0)}`)", + ] + if fork_remote: + lines.append(f"- Mirrored in `{fork_remote}`: `{mirrored_count}` of `{len(prs)}` PR refs") + if mirror_issues_repo: + lines.append(f"- Mirrored in `{mirror_issues_repo}`: `{mirrored_issue_count}` of `{len(issues)}` issues") + if coverage_map_path: + lines.append(f"- Local issue coverage map: `{coverage_map_path}`") + lines.extend(["", "## Recently Updated Issues", ""]) + + for issue in issues[:10]: + labels = ", ".join(issue["labels"]) if issue["labels"] else "no labels" + issue_suffix = "" + if issue.get("fork_issue_mirrored"): + issue_suffix = f", mirror=#{issue.get('fork_issue_number')}" + lines.append(f"- #{issue['number']} [{issue['state']}{issue_suffix}] {issue['title']} ({labels})") + local_coverage = issue.get("local_coverage") or {} + if local_coverage: + status = local_coverage.get("status") or "covered" + summary = local_coverage.get("summary") or "covered locally on this branch" + lines.append(f" - local coverage [{status}]: {summary}") + if issue.get("body_excerpt"): + lines.append(f" - {issue['body_excerpt']}") + if issue.get("recent_comments"): + latest_comment = issue["recent_comments"][0] + author = latest_comment.get("author") or "unknown" + excerpt = latest_comment.get("body_excerpt") or "(no comment body)" + lines.append(f" - latest comment by `{author}`: {excerpt}") + + lines.extend(["", "## Recently Updated Pull Requests", ""]) + for pr in prs[:10]: + suffix = " merged" if pr.get("merged_at") else "" + mergeable_state = pr.get("mergeable_state") or "unknown" + mirror_suffix = "" + if fork_remote: + mirror_suffix = ", mirrored=yes" if pr.get("fork_mirrored") else ", mirrored=no" + lines.append( + f"- #{pr['number']} [{pr['state']}{suffix}, mergeable={mergeable_state}{mirror_suffix}] " + f"{pr['title']} (`{pr['head']}` -> `{pr['base']}`)" + ) + local_coverage = pr.get("local_coverage") or {} + if local_coverage: + status = local_coverage.get("status") or "covered" + summary = local_coverage.get("summary") or "covered locally on this branch" + lines.append(f" - local coverage [{status}]: {summary}") + if pr.get("body_excerpt"): + lines.append(f" - {pr['body_excerpt']}") + if pr.get("recent_comments"): + latest_comment = pr["recent_comments"][0] + author = latest_comment.get("author") or "unknown" + excerpt = latest_comment.get("body_excerpt") or "(no comment body)" + lines.append(f" - latest comment by `{author}`: {excerpt}") + + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def attach_local_coverage( + items: list[dict[str, Any]], + coverage_map: dict[int, dict[str, object]] | None, + item_type: str = "issue", +) -> list[dict[str, Any]]: + attached: list[dict[str, Any]] = [] + for item in items: + if not isinstance(item, dict): + continue + number = item.get("number") + if not isinstance(number, int): + attached.append(item) + continue + enriched = dict(item) + local_coverage = (coverage_map or {}).get(number) + enriched = attach_local_coverage_fields(enriched, local_coverage) + if item_type == "pull_request": + enriched = attach_pr_review_fields(enriched, local_coverage) + attached.append(enriched) + return attached + + +def load_cached_snapshot(path: Path, repo: str, state: str) -> dict[str, Any] | None: + if not path.exists(): + return None + + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + + if not isinstance(payload, dict): + return None + if payload.get("repo") != repo or payload.get("state") != state: + return None + + return payload + + +def snapshot_timestamp(payload: dict[str, Any]) -> Any: + return payload.get("captured_at") or payload.get("generated_at") or payload.get("refreshed_at") + + +def snapshot_is_fresh(payload: dict[str, Any], stale_after_hours: int) -> bool: + captured_at = snapshot_timestamp(payload) + if not captured_at or stale_after_hours < 0: + return False + + try: + captured = datetime.fromisoformat(str(captured_at).replace("Z", "+00:00")) + except ValueError: + return False + + age_seconds = (datetime.now(timezone.utc) - captured.astimezone(timezone.utc)).total_seconds() + return age_seconds <= stale_after_hours * 3600 + + +def try_reuse_cached_snapshot( + cached_payload: dict[str, Any] | None, + *, + repo: str, + state: str, + output_path: Path, + summary_path: Path, + exc: RuntimeError, + stale_cache_hours: int, + fork_remote: str | None = None, + mirror_issues_repo: str | None = None, + issue_coverage_map: dict[int, dict[str, object]] | None = None, + pr_coverage_map: dict[int, dict[str, object]] | None = None, + coverage_map_path: str | None = None, +) -> bool: + rate_limited = "rate limit" in str(exc).lower() + if not rate_limited or not cached_payload or not snapshot_is_fresh(cached_payload, stale_cache_hours): + return False + + issues = cached_payload.get("issues") or [] + prs = cached_payload.get("pull_requests") or [] + captured_at = snapshot_timestamp(cached_payload) + if not isinstance(issues, list) or not isinstance(prs, list): + return False + issues = attach_local_coverage(issues, issue_coverage_map, item_type="issue") + prs = attach_local_coverage(prs, pr_coverage_map, item_type="pull_request") + refreshed_payload = dict(cached_payload) + refreshed_payload.pop("_cache_path", None) + refreshed_payload["issues"] = issues + refreshed_payload["pull_requests"] = prs + refreshed_payload["refreshed_at"] = captured_at + refreshed_payload["coverage_map_path"] = coverage_map_path + resolved_fork_remote = fork_remote or refreshed_payload.get("fork_remote") + resolved_mirror_issues_repo = mirror_issues_repo or refreshed_payload.get("mirror_issues_repo") + if resolved_fork_remote: + refreshed_payload["fork_remote"] = resolved_fork_remote + if resolved_mirror_issues_repo: + refreshed_payload["mirror_issues_repo"] = resolved_mirror_issues_repo + output_path.write_text(json.dumps(refreshed_payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + write_summary( + summary_path, + repo, + state, + issues, + prs, + resolved_fork_remote, + mirror_issues_repo=resolved_mirror_issues_repo, + captured_at=captured_at, + coverage_map_path=coverage_map_path or cached_payload.get("coverage_map_path"), + ) + print( + f"warning: {exc}; reusing fresh cached snapshot from " + f"{cached_payload.get('_cache_path', 'cache')} (captured_at={captured_at})", + file=sys.stderr, + ) + print( + f"Reused cached snapshot with {len(issues)} issues and {len(prs)} pull requests " + f"from {repo} into {os.path.relpath(cached_payload.get('_cache_path', summary_path))}" + ) + return True + + +def lock_path_for(output_path: Path, repo: str) -> Path: + repo_slug = re.sub(r"[^A-Za-z0-9._-]+", "-", repo) + repo_root = output_path.resolve().parents[1] + return repo_root / ".agents" / "upstream-sync-locks" / f"{repo_slug}.lock" + + +@contextlib.contextmanager +def repo_lock(output_path: Path, repo: str, wait_timeout: float = 0.0, poll_interval: float = 0.1): + lock_path = lock_path_for(output_path, repo) + lock_path.parent.mkdir(parents=True, exist_ok=True) + with open(lock_path, "w", encoding="utf-8") as lock_file: + deadline = time.monotonic() + max(0.0, wait_timeout) + while True: + try: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except OSError as exc: + if exc.errno not in (errno.EACCES, errno.EAGAIN): + raise + if time.monotonic() >= deadline: + raise RuntimeError( + f"Another sync_upstream_github.py run is already refreshing {repo}. " + f"Wait for it to finish and rerun sequentially, or increase --lock-wait-seconds " + f"(current={wait_timeout:g})." + ) from exc + time.sleep(max(0.01, poll_interval)) + lock_file.write(str(os.getpid())) + lock_file.flush() + try: + yield + finally: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "repo_arg", + nargs="?", + help="Legacy positional owner/repo alias retained for backward compatibility", + ) + parser.add_argument( + "--repo", + default=argparse.SUPPRESS, + help="owner/repo to inspect", + ) + parser.add_argument("--state", default="open", help="GitHub state filter (open, closed, or all)") + parser.add_argument("--limit", type=int, default=500, help="Maximum items to fetch per collection") + parser.add_argument( + "--timeout", + type=int, + default=DEFAULT_API_TIMEOUT, + help="Timeout in seconds for each gh/http request", + ) + parser.add_argument( + "--output", + "--json-out", + dest="output", + required=True, + help="Path to write machine-readable JSON", + ) + parser.add_argument( + "--summary", + "--md-out", + dest="summary", + required=True, + help="Path to write markdown summary", + ) + parser.add_argument( + "--fork-remote", + default=None, + help="Optional git remote name used to annotate whether upstream PR refs are mirrored into the fork", + ) + parser.add_argument( + "--mirror-issues-repo", + default=None, + help="Optional owner/repo used to mirror upstream issues into fork GitHub issues", + ) + parser.add_argument( + "--stale-cache-hours", + type=int, + default=DEFAULT_STALE_CACHE_HOURS, + help=( + "If refresh hits a GitHub rate limit, reuse an existing snapshot captured within this many hours " + "instead of failing. Set to -1 to disable stale cache fallback." + ), + ) + parser.add_argument( + "--max-workers", + type=int, + default=DEFAULT_MAX_WORKERS, + help=( + "Maximum concurrent GitHub hydration workers for per-item PR detail/comment fetches. " + "Lower this if GitHub starts rate limiting aggressively." + ), + ) + parser.add_argument( + "--lock-wait-seconds", + type=float, + default=5.0, + help=( + "How long to wait for another sync_upstream_github.py run holding the repo lock " + "before failing. Defaults to 5 seconds so sequential refreshes can serialize cleanly." + ), + ) + parser.add_argument( + "--coverage-map", + default="docs/upstream-coverage.json", + help=( + "Optional machine-readable JSON file describing upstream issues already covered locally. " + "Defaults to docs/upstream-coverage.json when present." + ), + ) + return parser + + +def resolve_repo_argument(args: argparse.Namespace, parser: argparse.ArgumentParser) -> str: + positional_repo = getattr(args, "repo_arg", None) + default_repo = DEFAULT_REPO + explicit_repo = getattr(args, "repo", None) + option_repo = explicit_repo or default_repo + + if positional_repo: + if explicit_repo is not None and positional_repo != option_repo: + parser.error( + f"conflicting repo values: positional {positional_repo!r} does not match --repo {option_repo!r}" + ) + return positional_repo + + return option_repo + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + args.repo = resolve_repo_argument(args, parser) + global REQUEST_TIMEOUT + REQUEST_TIMEOUT = max(1, args.timeout) + max_workers = max(1, args.max_workers) + + output_path = Path(args.output) + summary_path = Path(args.summary) + coverage_map_path = Path(args.coverage_map) if args.coverage_map else None + issue_coverage_map = load_local_issue_coverage(coverage_map_path) + pr_coverage_map = load_local_pr_coverage(coverage_map_path) + cached_payload = load_cached_snapshot(output_path, args.repo, args.state) + if cached_payload is not None: + cached_payload["_cache_path"] = str(output_path) + owner, name = args.repo.split("/", 1) + + with repo_lock(output_path, args.repo, wait_timeout=args.lock_wait_seconds): + try: + issue_items = github_api_paginated( + f"/repos/{owner}/{name}/issues", + {"state": args.state, "sort": "updated", "direction": "desc"}, + args.limit, + ) + pr_items = github_api_paginated( + f"/repos/{owner}/{name}/pulls", + {"state": args.state, "sort": "updated", "direction": "desc"}, + args.limit, + ) + except RuntimeError as exc: + if try_reuse_cached_snapshot( + cached_payload, + repo=args.repo, + state=args.state, + output_path=output_path, + summary_path=summary_path, + exc=exc, + stale_cache_hours=args.stale_cache_hours, + fork_remote=args.fork_remote, + mirror_issues_repo=args.mirror_issues_repo, + issue_coverage_map=issue_coverage_map, + pr_coverage_map=pr_coverage_map, + coverage_map_path=str(coverage_map_path) if coverage_map_path and coverage_map_path.exists() else None, + ): + return 0 + raise + + try: + issues = compact_issues( + [item for item in issue_items if "pull_request" not in item], + max_workers=max_workers, + coverage_map=issue_coverage_map, + ) + if args.mirror_issues_repo: + issues = mirror_issues_to_fork(args.mirror_issues_repo, args.repo, issues) + pr_details = hydrate_pull_requests(owner, name, pr_items, max_workers=max_workers) + mirrored_pr_numbers = ( + list_mirrored_pull_request_numbers(args.fork_remote) if args.fork_remote else None + ) + if args.fork_remote: + mirrored_pr_numbers = mirror_pull_request_refs( + args.repo, + args.fork_remote, + pr_details, + mirrored_pr_numbers, + ) + prs = compact_pull_requests( + pr_details, + mirrored_pr_numbers, + args.fork_remote, + max_workers=max_workers, + coverage_map=pr_coverage_map, + ) + except RuntimeError as exc: + if try_reuse_cached_snapshot( + cached_payload, + repo=args.repo, + state=args.state, + output_path=output_path, + summary_path=summary_path, + exc=exc, + stale_cache_hours=args.stale_cache_hours, + fork_remote=args.fork_remote, + mirror_issues_repo=args.mirror_issues_repo, + issue_coverage_map=issue_coverage_map, + pr_coverage_map=pr_coverage_map, + coverage_map_path=str(coverage_map_path) if coverage_map_path and coverage_map_path.exists() else None, + ): + return 0 + raise + captured_at = datetime.now(timezone.utc).isoformat() + payload = { + "repo": args.repo, + "state": args.state, + "captured_at": captured_at, + "generated_at": captured_at, + "refreshed_at": captured_at, + "coverage_map_path": str(coverage_map_path) if coverage_map_path and coverage_map_path.exists() else None, + "counts": { + "issues": summarize_counts(issues), + "pull_requests": summarize_counts(prs), + }, + "issues": issues, + "pull_requests": prs, + } + if args.fork_remote: + mirrored_total = sum(1 for pr in prs if pr["fork_mirrored"]) + payload["fork_remote"] = args.fork_remote + payload["counts"]["mirrored_pull_requests"] = { + "mirrored": mirrored_total, + "not_mirrored": len(prs) - mirrored_total, + } + if args.mirror_issues_repo: + mirrored_issue_total = sum(1 for issue in issues if issue.get("fork_issue_mirrored")) + payload["mirror_issues_repo"] = args.mirror_issues_repo + payload["counts"]["mirrored_issues"] = { + "mirrored": mirrored_issue_total, + "not_mirrored": len(issues) - mirrored_issue_total, + } + + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + write_summary( + summary_path, + args.repo, + args.state, + issues, + prs, + args.fork_remote, + mirror_issues_repo=args.mirror_issues_repo, + captured_at=captured_at, + coverage_map_path=str(coverage_map_path) if coverage_map_path and coverage_map_path.exists() else None, + ) + + print( + f"Captured {len(issues)} issues and {len(prs)} pull requests from {args.repo} " + f"into {os.path.relpath(output_path)}" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/test_backend_lite.sh b/scripts/test_backend_lite.sh new file mode 100755 index 00000000..bb97a981 --- /dev/null +++ b/scripts/test_backend_lite.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +VENV_DIR="${ROOT_DIR}/.tmp-test-venv" + +if [ ! -d "${VENV_DIR}" ]; then + python3 -m venv "${VENV_DIR}" +fi + +# Keep the lightweight path limited to the dependencies needed by the fast unit tests. +"${VENV_DIR}/bin/pip" install -q \ + "flask>=3.0.0" \ + "flask-cors>=5.0.0" \ + "pytest>=8.0.0" \ + "openai>=1.0.0" \ + "python-dotenv>=1.0.0" + +"${VENV_DIR}/bin/pytest" \ + "${ROOT_DIR}/backend/tests/test_app_routes.py" \ + "${ROOT_DIR}/backend/tests/test_config.py" \ + "${ROOT_DIR}/backend/tests/test_i18n.py" \ + "${ROOT_DIR}/backend/tests/test_llm_env.py" \ + "${ROOT_DIR}/backend/tests/test_llm_client.py" \ + "${ROOT_DIR}/backend/tests/test_graph_upload_api.py" \ + "${ROOT_DIR}/backend/tests/test_graph_builder.py" \ + "${ROOT_DIR}/backend/tests/test_report_agent.py" \ + "${ROOT_DIR}/backend/tests/test_simulation_api_i18n.py" \ + "${ROOT_DIR}/backend/tests/test_simulation_runner_actions.py" \ + "${ROOT_DIR}/backend/tests/test_print_config_status.py" \ + "${ROOT_DIR}/backend/tests/test_openai_compat_services.py" \ + -q diff --git a/scripts/validate_repo.sh b/scripts/validate_repo.sh new file mode 100755 index 00000000..0988194b --- /dev/null +++ b/scripts/validate_repo.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +RUN_BACKEND=1 +RUN_FRONTEND=1 +RUN_FRONTEND_BUILD=1 + +usage() { + cat <<'EOF' +Usage: bash ./scripts/validate_repo.sh [options] + +Options: + --backend-only Run only the lightweight backend test bundle. + --frontend-only Run only the frontend checks. + --skip-frontend-build Skip the production frontend build step. + --help Show this help message. +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --backend-only) + RUN_FRONTEND=0 + ;; + --frontend-only) + RUN_BACKEND=0 + ;; + --skip-frontend-build) + RUN_FRONTEND_BUILD=0 + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac + shift +done + +if [ "$RUN_BACKEND" -eq 0 ] && [ "$RUN_FRONTEND" -eq 0 ]; then + echo "Nothing to run: both backend and frontend checks are disabled." >&2 + exit 1 +fi + +cd "$ROOT_DIR" + +if [ "$RUN_BACKEND" -eq 1 ]; then + echo "==> Running lightweight backend test bundle" + bash ./scripts/test_backend_lite.sh +fi + +if [ "$RUN_FRONTEND" -eq 1 ]; then + echo "==> Running frontend tests" + npm --prefix frontend test + + if [ "$RUN_FRONTEND_BUILD" -eq 1 ]; then + echo "==> Running frontend production build" + npm --prefix frontend run build + fi +fi diff --git a/tests/test_refresh_upstream_snapshots.py b/tests/test_refresh_upstream_snapshots.py new file mode 100644 index 00000000..ba527dc0 --- /dev/null +++ b/tests/test_refresh_upstream_snapshots.py @@ -0,0 +1,99 @@ +import json +import os +import shutil +import stat +import subprocess +import tempfile +import textwrap +import unittest +from pathlib import Path + + +class RefreshUpstreamSnapshotsTests(unittest.TestCase): + def _write_fake_sync_script(self, path: Path) -> None: + path.write_text( + textwrap.dedent( + """\ + #!/usr/bin/env python3 + import json + import os + import sys + + log_path = os.environ["SYNC_LOG_PATH"] + with open(log_path, "a", encoding="utf-8") as handle: + handle.write(json.dumps(sys.argv[1:]) + "\\n") + """ + ), + encoding="utf-8", + ) + path.chmod(path.stat().st_mode | stat.S_IEXEC) + + def _run_wrapper(self, *args: str) -> list[list[str]]: + wrapper_source = ( + Path(__file__).resolve().parents[1] / "scripts" / "refresh_upstream_snapshots.sh" + ) + + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + scripts_dir = tmp_path / "scripts" + docs_dir = tmp_path / "docs" + scripts_dir.mkdir() + docs_dir.mkdir() + + wrapper_copy = scripts_dir / "refresh_upstream_snapshots.sh" + shutil.copy2(wrapper_source, wrapper_copy) + + fake_sync = scripts_dir / "sync_upstream_github.py" + self._write_fake_sync_script(fake_sync) + + log_path = tmp_path / "sync-log.jsonl" + env = os.environ.copy() + env["SYNC_LOG_PATH"] = str(log_path) + + subprocess.run( + ["bash", str(wrapper_copy), *args], + cwd=tmp_path, + env=env, + check=True, + ) + + return [ + json.loads(line) + for line in log_path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + + def test_force_refresh_keeps_default_repo_and_disables_stale_cache(self): + calls = self._run_wrapper("--force-refresh") + + self.assertEqual(len(calls), 2) + for call in calls: + self.assertIn("--repo", call) + self.assertEqual(call[call.index("--repo") + 1], "666ghj/MiroFish") + self.assertIn("--stale-cache-hours", call) + self.assertEqual(call[call.index("--stale-cache-hours") + 1], "-1") + + def test_positional_repo_and_timeout_are_forwarded(self): + calls = self._run_wrapper("example/MiroFish", "--timeout", "22") + + self.assertEqual(len(calls), 2) + for call in calls: + self.assertEqual(call[call.index("--repo") + 1], "example/MiroFish") + self.assertEqual(call[call.index("--timeout") + 1], "22") + + def test_fork_mirror_overrides_are_forwarded(self): + calls = self._run_wrapper( + "--fork-remote", + "upstream-fork", + "--mirror-issues-repo", + "example/fork", + ) + + self.assertEqual(len(calls), 2) + for call in calls: + self.assertEqual(call[call.index("--fork-remote") + 1], "upstream-fork") + self.assertEqual(call[call.index("--mirror-issues-repo") + 1], "example/fork") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_setup_backend_simulation.py b/tests/test_setup_backend_simulation.py new file mode 100644 index 00000000..24785a75 --- /dev/null +++ b/tests/test_setup_backend_simulation.py @@ -0,0 +1,21 @@ +import unittest + +from scripts.setup_backend_simulation import validate_environment + + +class ValidateEnvironmentTests(unittest.TestCase): + def test_allows_python_312_without_rust(self): + self.assertIsNone(validate_environment(version_info=(3, 12), rustc_available=False)) + + def test_blocks_python_313_without_rust(self): + error = validate_environment(version_info=(3, 13), rustc_available=False) + self.assertIsNotNone(error) + self.assertIn("Python 3.13", error) + self.assertIn("Rust", error) + + def test_allows_python_313_with_rust(self): + self.assertIsNone(validate_environment(version_info=(3, 13), rustc_available=True)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_simulation_cli_help.py b/tests/test_simulation_cli_help.py new file mode 100644 index 00000000..96d05e86 --- /dev/null +++ b/tests/test_simulation_cli_help.py @@ -0,0 +1,54 @@ +import os +import subprocess +import sys +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +class SimulationCliHelpTests(unittest.TestCase): + def _run_help(self, relative_path: str) -> subprocess.CompletedProcess[str]: + script_path = ROOT / relative_path + env = os.environ.copy() + env["MIROFISH_LOCALE"] = "en" + return subprocess.run( + [sys.executable, str(script_path), "--help"], + cwd=ROOT, + env=env, + capture_output=True, + text=True, + check=False, + ) + + def test_reddit_help_is_available_without_optional_runtime_dependencies(self): + result = self._run_help("backend/scripts/run_reddit_simulation.py") + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("OASIS Reddit simulation", result.stdout) + self.assertIn("Path to the configuration file", result.stdout) + self.assertNotIn("missing dependency", result.stdout.lower()) + self.assertNotIn("Simulation process exited", result.stdout) + + def test_twitter_help_is_available_without_optional_runtime_dependencies(self): + result = self._run_help("backend/scripts/run_twitter_simulation.py") + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("OASIS Twitter simulation", result.stdout) + self.assertIn("Maximum simulation rounds", result.stdout) + self.assertNotIn("missing dependency", result.stdout.lower()) + self.assertNotIn("Simulation process exited", result.stdout) + + def test_parallel_help_is_available_without_optional_runtime_dependencies(self): + result = self._run_help("backend/scripts/run_parallel_simulation.py") + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("OASIS dual-platform parallel simulation", result.stdout) + self.assertIn("Run only the Reddit simulation", result.stdout) + self.assertNotIn("missing dependency", result.stdout.lower()) + self.assertNotIn("Simulation process exited", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sync_upstream_github.py b/tests/test_sync_upstream_github.py new file mode 100644 index 00000000..4c1c66e0 --- /dev/null +++ b/tests/test_sync_upstream_github.py @@ -0,0 +1,1723 @@ +import unittest +import io +import json +import tempfile +import threading +import time +from contextlib import nullcontext +from pathlib import Path +from unittest.mock import patch +import importlib.util +import subprocess +from urllib.error import HTTPError + + +def load_module(): + script_path = Path(__file__).resolve().parents[1] / "scripts" / "sync_upstream_github.py" + spec = importlib.util.spec_from_file_location("sync_upstream_github", script_path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +sync_upstream_github = load_module() + + +class SyncUpstreamGithubTests(unittest.TestCase): + def setUp(self): + sync_upstream_github.GH_CLI_USABLE = None + sync_upstream_github.GH_CLI_DISABLED_REASON = None + + def test_load_local_issue_coverage_reads_machine_readable_map(self): + with tempfile.TemporaryDirectory() as tmpdir: + coverage_path = Path(tmpdir) / "coverage.json" + coverage_path.write_text( + json.dumps( + { + "issues": [ + {"number": 133, "status": "covered", "summary": "Root endpoint returns backend status"}, + {"number": 139, "status": "covered", "summary": "Zep auth errors are sanitized"}, + ] + } + ), + encoding="utf-8", + ) + + coverage = sync_upstream_github.load_local_issue_coverage(coverage_path) + + self.assertEqual(sorted(coverage), [133, 139]) + self.assertEqual(coverage[139]["status"], "covered") + + def test_compact_issue_includes_local_coverage_when_available(self): + issue = { + "number": 139, + "title": "Graph build task failed", + "html_url": "https://example.test/issues/139", + "state": "open", + "created_at": "2026-03-10T00:00:00Z", + "updated_at": "2026-03-11T00:00:00Z", + "labels": [], + "user": {"login": "alice"}, + "body": "provider traceback", + "comments": 0, + } + + compacted = sync_upstream_github.compact_issue( + issue, + {139: {"number": 139, "status": "covered", "summary": "Auth failures are sanitized"}}, + ) + + self.assertEqual(compacted["local_coverage"]["status"], "covered") + self.assertEqual(compacted["local_coverage"]["summary"], "Auth failures are sanitized") + self.assertEqual(compacted["local_status"], "covered") + self.assertEqual(compacted["local_summary"], "Auth failures are sanitized") + self.assertEqual(compacted["triage_status"], "covered") + self.assertEqual(compacted["summary"], "Auth failures are sanitized") + self.assertEqual(compacted["coverage_status"], "covered") + self.assertEqual(compacted["coverage_summary"], "Auth failures are sanitized") + + def test_compact_issue_promotes_body_excerpt_when_untracked(self): + issue = { + "number": 140, + "title": "General commentary", + "html_url": "https://example.test/issues/140", + "state": "open", + "created_at": "2026-03-10T00:00:00Z", + "updated_at": "2026-03-11T00:00:00Z", + "labels": [], + "user": {"login": "alice"}, + "body": "This is the upstream body excerpt.", + "comments": 0, + } + + compacted = sync_upstream_github.compact_issue(issue, {}) + + self.assertEqual(compacted["triage_status"], "untracked") + self.assertEqual(compacted["summary"], "This is the upstream body excerpt.") + + def test_build_mirror_issue_body_includes_markers_and_local_coverage(self): + body = sync_upstream_github.build_mirror_issue_body( + "666ghj/MiroFish", + { + "number": 145, + "title": "Duplicate entity nodes", + "url": "https://example.test/issues/145", + "state": "open", + "updated_at": "2026-03-11T15:00:00Z", + "labels": ["bug"], + "author": "alice", + "body_excerpt": "Graph contains duplicate entities", + "recent_comments": [ + { + "author": "bob", + "created_at": "2026-03-11T15:01:00Z", + "body_excerpt": "I can reproduce this too.", + } + ], + "local_coverage": { + "status": "tracked", + "summary": "Tracked in beads for repo-native follow-up", + "local_refs": [".beads/issues.jsonl", "docs/upstream-triage.md"], + }, + }, + ) + + self.assertIn("<!-- mirofish-upstream-repo:666ghj/MiroFish -->", body) + self.assertIn("<!-- mirofish-upstream-issue:145 -->", body) + self.assertIn("Tracked in beads for repo-native follow-up", body) + self.assertIn("`bob` at `2026-03-11T15:01:00Z`", body) + + def test_extract_upstream_issue_marker_reads_repo_and_number(self): + marker = sync_upstream_github.extract_upstream_issue_marker( + "<!-- mirofish-upstream-repo:666ghj/MiroFish -->\n" + "<!-- mirofish-upstream-issue:145 -->\n" + ) + + self.assertEqual(marker, ("666ghj/MiroFish", 145)) + + def test_attach_fork_issue_mirror_metadata_marks_mirrored_entries(self): + attached = sync_upstream_github.attach_fork_issue_mirror_metadata( + [{"number": 145, "title": "Duplicate entity nodes"}], + {145: {"number": 12, "url": "https://example.test/issues/12"}}, + ) + + self.assertTrue(attached[0]["fork_issue_mirrored"]) + self.assertEqual(attached[0]["fork_issue_number"], 12) + self.assertEqual(attached[0]["fork_issue_url"], "https://example.test/issues/12") + + def test_load_local_pr_coverage_reads_machine_readable_map(self): + with tempfile.TemporaryDirectory() as tmpdir: + coverage_path = Path(tmpdir) / "coverage.json" + coverage_path.write_text( + json.dumps( + { + "pull_requests": [ + {"number": 125, "status": "landed", "summary": "Diagnostics landed locally"}, + {"number": 118, "status": "not_safe", "summary": "Needs a backend abstraction redesign"}, + ] + } + ), + encoding="utf-8", + ) + + coverage = sync_upstream_github.load_local_pr_coverage(coverage_path) + + self.assertEqual(sorted(coverage), [118, 125]) + self.assertEqual(coverage[125]["status"], "landed") + + def test_compact_pr_includes_local_coverage_when_available(self): + pr = { + "number": 125, + "title": "Improve diagnostics", + "html_url": "https://example.test/pull/125", + "state": "open", + "created_at": "2026-03-10T00:00:00Z", + "updated_at": "2026-03-11T00:00:00Z", + "head": {"ref": "fix/issue-121", "sha": "abc123", "repo": {"full_name": "fork/repo", "clone_url": "https://example.test/fork/repo.git"}}, + "base": {"ref": "main", "repo": {"full_name": "666ghj/MiroFish"}}, + "draft": False, + "mergeable_state": "clean", + "labels": [], + "user": {"login": "alice"}, + "body": "PR body", + "comments": 0, + "review_comments": 0, + } + + compacted = sync_upstream_github.compact_pr( + pr, + mirrored_pr_numbers={125}, + fork_remote="origin", + coverage_map={125: {"number": 125, "status": "landed", "summary": "Diagnostics landed locally"}}, + ) + + self.assertEqual(compacted["local_coverage"]["status"], "landed") + self.assertEqual(compacted["local_status"], "landed") + self.assertEqual(compacted["local_summary"], "Diagnostics landed locally") + self.assertEqual(compacted["triage_status"], "landed") + self.assertEqual(compacted["summary"], "Diagnostics landed locally") + self.assertEqual(compacted["coverage_status"], "landed") + self.assertEqual(compacted["coverage_summary"], "Diagnostics landed locally") + self.assertEqual(compacted["local_review"]["status"], "landed") + self.assertEqual(compacted["local_review"]["summary"], "Diagnostics landed locally") + self.assertEqual(compacted["head_ref_name"], "fix/issue-121") + self.assertEqual(compacted["base_ref_name"], "main") + self.assertTrue(compacted["mirrored_to_origin"]) + self.assertEqual(compacted["mirror_ref"], "origin/mirror/upstream-pr-125") + self.assertEqual(compacted["fork_mirror_ref"], "origin/mirror/upstream-pr-125") + + def test_attach_local_coverage_promotes_status_fields(self): + attached = sync_upstream_github.attach_local_coverage( + [{"number": 145, "title": "Duplicate entity nodes"}], + {145: {"number": 145, "status": "tracked", "summary": "Tracked in beads"}}, + ) + + self.assertEqual(attached[0]["local_status"], "tracked") + self.assertEqual(attached[0]["local_summary"], "Tracked in beads") + self.assertEqual(attached[0]["triage_status"], "tracked") + self.assertEqual(attached[0]["summary"], "Tracked in beads") + self.assertEqual(attached[0]["coverage_status"], "tracked") + self.assertEqual(attached[0]["coverage_summary"], "Tracked in beads") + + def test_attach_local_coverage_adds_pr_review_fields_for_pull_requests(self): + attached = sync_upstream_github.attach_local_coverage( + [{"number": 125, "title": "Improve diagnostics", "body_excerpt": "PR body"}], + {125: {"number": 125, "status": "landed", "summary": "Diagnostics landed locally"}}, + item_type="pull_request", + ) + + self.assertEqual(attached[0]["local_review"]["status"], "landed") + self.assertEqual(attached[0]["local_review"]["summary"], "Diagnostics landed locally") + + def test_attach_local_coverage_marks_unreviewed_pull_requests_without_local_map_entry(self): + attached = sync_upstream_github.attach_local_coverage( + [{"number": 144, "title": "Dual-mode knowledge graph", "body_excerpt": "PR body"}], + {}, + item_type="pull_request", + ) + + self.assertEqual(attached[0]["local_review"]["status"], "unreviewed") + self.assertEqual(attached[0]["local_review"]["summary"], "PR body") + + def test_write_summary_includes_local_coverage_notes(self): + with tempfile.TemporaryDirectory() as tmpdir: + summary_path = Path(tmpdir) / "summary.md" + sync_upstream_github.write_summary( + summary_path, + "666ghj/MiroFish", + "open", + [ + { + "number": 133, + "title": "Backend access confusion", + "state": "open", + "labels": ["question"], + "body_excerpt": "Backend root returned 404", + "recent_comments": [], + "local_coverage": { + "number": 133, + "status": "covered", + "summary": "Root and health endpoints now return backend status JSON", + }, + } + ], + [], + coverage_map_path="docs/upstream-coverage.json", + captured_at="2026-03-11T09:00:00+00:00", + ) + + summary = summary_path.read_text(encoding="utf-8") + + self.assertIn("Local issue coverage map: `docs/upstream-coverage.json`", summary) + self.assertIn("local coverage [covered]: Root and health endpoints now return backend status JSON", summary) + + def test_write_summary_includes_mirrored_issue_count(self): + with tempfile.TemporaryDirectory() as tmpdir: + summary_path = Path(tmpdir) / "summary.md" + sync_upstream_github.write_summary( + summary_path, + "666ghj/MiroFish", + "open", + [ + { + "number": 145, + "title": "Duplicate entity nodes", + "state": "open", + "labels": [], + "body_excerpt": "Body", + "recent_comments": [], + "fork_issue_mirrored": True, + "fork_issue_number": 12, + } + ], + [], + mirror_issues_repo="ivanzud/MiroFish", + captured_at="2026-03-11T09:00:00+00:00", + ) + + summary = summary_path.read_text(encoding="utf-8") + + self.assertIn("Mirrored in `ivanzud/MiroFish`: `1` of `1` issues", summary) + self.assertIn("[open, mirror=#12]", summary) + + def test_write_summary_includes_pull_request_local_coverage_notes(self): + with tempfile.TemporaryDirectory() as tmpdir: + summary_path = Path(tmpdir) / "summary.md" + sync_upstream_github.write_summary( + summary_path, + "666ghj/MiroFish", + "open", + [], + [ + { + "number": 125, + "title": "Improve diagnostics", + "state": "open", + "head": "fix/issue-121", + "base": "main", + "mergeable_state": "clean", + "fork_mirrored": True, + "body_excerpt": "PR body", + "recent_comments": [], + "local_coverage": { + "number": 125, + "status": "landed", + "summary": "Diagnostics landed locally", + }, + } + ], + fork_remote="origin", + coverage_map_path="docs/upstream-coverage.json", + captured_at="2026-03-11T09:00:00+00:00", + ) + + summary = summary_path.read_text(encoding="utf-8") + + self.assertIn("local coverage [landed]: Diagnostics landed locally", summary) + + def test_build_parser_accepts_legacy_output_flag_names(self): + args = sync_upstream_github.build_parser().parse_args( + [ + "--repo", + "666ghj/MiroFish", + "--json-out", + "docs/upstream-open-state.json", + "--md-out", + "docs/upstream-open-summary.md", + ] + ) + + self.assertEqual(args.output, "docs/upstream-open-state.json") + self.assertEqual(args.summary, "docs/upstream-open-summary.md") + + def test_build_parser_accepts_max_workers_flag(self): + args = sync_upstream_github.build_parser().parse_args( + [ + "--output", + "docs/upstream-open-state.json", + "--summary", + "docs/upstream-open-summary.md", + "--max-workers", + "4", + ] + ) + + self.assertEqual(args.max_workers, 4) + + def test_build_parser_defaults_lock_wait_seconds_to_five(self): + args = sync_upstream_github.build_parser().parse_args( + [ + "--output", + "docs/upstream-open-state.json", + "--summary", + "docs/upstream-open-summary.md", + ] + ) + + self.assertEqual(args.lock_wait_seconds, 5.0) + + def test_build_parser_accepts_legacy_positional_repo_argument(self): + parser = sync_upstream_github.build_parser() + args = parser.parse_args( + [ + "666ghj/MiroFish", + "--output", + "docs/upstream-open-state.json", + "--summary", + "docs/upstream-open-summary.md", + ] + ) + + resolved_repo = sync_upstream_github.resolve_repo_argument(args, parser) + + self.assertEqual(resolved_repo, "666ghj/MiroFish") + + def test_resolve_repo_argument_rejects_conflicting_repo_values(self): + parser = sync_upstream_github.build_parser() + args = parser.parse_args( + [ + "fork/MiroFish", + "--repo", + "666ghj/MiroFish", + "--output", + "docs/upstream-open-state.json", + "--summary", + "docs/upstream-open-summary.md", + ] + ) + + with self.assertRaises(SystemExit): + sync_upstream_github.resolve_repo_argument(args, parser) + + def test_snapshot_is_fresh_accepts_recent_capture(self): + payload = {"captured_at": "2026-03-11T08:30:00+00:00"} + + with patch.object(sync_upstream_github, "datetime") as mocked_datetime: + mocked_datetime.now.return_value = __import__("datetime").datetime(2026, 3, 11, 9, 0, tzinfo=__import__("datetime").timezone.utc) + mocked_datetime.fromisoformat = __import__("datetime").datetime.fromisoformat + self.assertTrue(sync_upstream_github.snapshot_is_fresh(payload, 24)) + + def test_snapshot_is_fresh_rejects_old_capture(self): + payload = {"captured_at": "2026-03-09T08:30:00+00:00"} + + with patch.object(sync_upstream_github, "datetime") as mocked_datetime: + mocked_datetime.now.return_value = __import__("datetime").datetime(2026, 3, 11, 9, 0, tzinfo=__import__("datetime").timezone.utc) + mocked_datetime.fromisoformat = __import__("datetime").datetime.fromisoformat + self.assertFalse(sync_upstream_github.snapshot_is_fresh(payload, 24)) + + def test_snapshot_is_fresh_accepts_legacy_generated_at(self): + payload = {"generated_at": "2026-03-11T08:30:00+00:00"} + + with patch.object(sync_upstream_github, "datetime") as mocked_datetime: + mocked_datetime.now.return_value = __import__("datetime").datetime(2026, 3, 11, 9, 0, tzinfo=__import__("datetime").timezone.utc) + mocked_datetime.fromisoformat = __import__("datetime").datetime.fromisoformat + self.assertTrue(sync_upstream_github.snapshot_is_fresh(payload, 24)) + + def test_snapshot_is_fresh_accepts_refreshed_at_alias(self): + payload = {"refreshed_at": "2026-03-11T08:30:00+00:00"} + + with patch.object(sync_upstream_github, "datetime") as mocked_datetime: + mocked_datetime.now.return_value = __import__("datetime").datetime(2026, 3, 11, 9, 0, tzinfo=__import__("datetime").timezone.utc) + mocked_datetime.fromisoformat = __import__("datetime").datetime.fromisoformat + self.assertTrue(sync_upstream_github.snapshot_is_fresh(payload, 24)) + + def test_normalize_excerpt_collapses_whitespace_and_truncates(self): + excerpt = sync_upstream_github.normalize_excerpt(" line 1\n\nline\t2 " * 20, limit=30) + + self.assertEqual(excerpt, "line 1 line 2 line 1 line 2 l…") + + def test_fetch_recent_comments_returns_compact_preview(self): + with patch.object( + sync_upstream_github, + "fetch_json", + return_value=[ + { + "user": {"login": "alice"}, + "created_at": "2026-01-02T00:00:00Z", + "updated_at": "2026-01-03T00:00:00Z", + "html_url": "https://example.test/comment/1", + "body": "First line\nSecond line", + } + ], + ) as mocked: + comments = sync_upstream_github.fetch_recent_comments("https://api.github.com/repos/test/repo/issues/10/comments") + + self.assertEqual( + comments, + [ + { + "author": "alice", + "created_at": "2026-01-02T00:00:00Z", + "updated_at": "2026-01-03T00:00:00Z", + "url": "https://example.test/comment/1", + "body_excerpt": "First line Second line", + } + ], + ) + mocked.assert_called_once() + self.assertIn("per_page=3", mocked.call_args.args[0]) + + def test_fetch_json_disables_gh_cli_after_rate_limit(self): + with patch.object(sync_upstream_github, "has_github_token", return_value=False), patch.object( + sync_upstream_github, + "can_use_gh_cli", + side_effect=lambda: not sync_upstream_github.GH_CLI_DISABLED_REASON, + ), patch.object( + sync_upstream_github, + "fetch_json_via_gh", + side_effect=RuntimeError("gh api failed: rate limit exceeded"), + ) as gh_fetch, patch.object( + sync_upstream_github, + "_fetch_json_via_http", + side_effect=[{"source": "http-1"}, {"source": "http-2"}], + ) as http_fetch: + first = sync_upstream_github.fetch_json("https://api.github.com/repos/test/repo/issues") + second = sync_upstream_github.fetch_json("https://api.github.com/repos/test/repo/pulls") + + self.assertEqual(first["source"], "http-1") + self.assertEqual(second["source"], "http-2") + self.assertEqual(gh_fetch.call_count, 1) + self.assertEqual(http_fetch.call_count, 2) + self.assertEqual(sync_upstream_github.GH_CLI_DISABLED_REASON, "GitHub CLI rate limited") + + def test_list_mirrored_pull_request_numbers_reads_remote_and_local_refs(self): + with patch.object( + sync_upstream_github.subprocess, + "run", + return_value=type( + "Completed", + (), + {"stdout": "origin/mirror/upstream-pr-101\nmirror/upstream-pr-102\norigin/main\n"}, + )(), + ) as mocked: + mirrored = sync_upstream_github.list_mirrored_pull_request_numbers("origin") + + self.assertEqual(mirrored, {101, 102}) + self.assertEqual( + mocked.call_args.args[0], + [ + "git", + "for-each-ref", + "--format=%(refname:short)", + "refs/remotes/origin/mirror/upstream-pr-*", + "refs/heads/mirror/upstream-pr-*", + ], + ) + + def test_mirror_pull_request_refs_fetches_and_pushes_missing_pr_heads(self): + pull_requests = [ + {"number": 155}, + {"number": 151}, + ] + + with patch.object(sync_upstream_github, "run_git_command", return_value="") as mocked: + mirrored = sync_upstream_github.mirror_pull_request_refs( + "666ghj/MiroFish", + "origin", + pull_requests, + mirrored_pr_numbers={151}, + ) + + self.assertEqual(mirrored, {151, 155}) + self.assertEqual( + mocked.call_args_list[0].args[0], + [ + "git", + "fetch", + "--force", + "https://github.com/666ghj/MiroFish.git", + "pull/155/head:refs/remotes/upstream-sync/pr-155", + ], + ) + self.assertEqual( + mocked.call_args_list[1].args[0], + [ + "git", + "push", + "origin", + "refs/remotes/upstream-sync/pr-155:refs/heads/mirror/upstream-pr-155", + ], + ) + self.assertEqual(len(mocked.call_args_list), 2) + + def test_mirror_pull_request_refs_warns_and_continues_when_push_fails(self): + pull_requests = [{"number": 155}, {"number": 156}] + + with ( + patch.object( + sync_upstream_github, + "run_git_command", + side_effect=[ + "", + RuntimeError("push failed"), + "", + "", + ], + ), + patch("sys.stderr", new_callable=io.StringIO) as stderr, + ): + mirrored = sync_upstream_github.mirror_pull_request_refs( + "666ghj/MiroFish", + "origin", + pull_requests, + ) + + self.assertEqual(mirrored, {156}) + self.assertIn("unable to mirror upstream PR #155", stderr.getvalue()) + + def test_fetch_json_prefers_authenticated_gh_cli_when_no_token(self): + with ( + patch.object(sync_upstream_github, "has_github_token", return_value=False), + patch.object(sync_upstream_github, "can_use_gh_cli", return_value=True), + patch.object(sync_upstream_github, "fetch_json_via_gh", return_value={"ok": True}) as mocked, + ): + payload = sync_upstream_github.fetch_json("https://api.github.com/repos/test/repo/issues?state=open") + + self.assertEqual(payload, {"ok": True}) + mocked.assert_called_once_with("https://api.github.com/repos/test/repo/issues?state=open") + + def test_fetch_json_falls_back_to_http_when_gh_cli_request_fails(self): + with ( + patch.object(sync_upstream_github, "has_github_token", return_value=False), + patch.object(sync_upstream_github, "can_use_gh_cli", return_value=True), + patch.object(sync_upstream_github, "fetch_json_via_gh", side_effect=RuntimeError("boom")), + patch.object(sync_upstream_github, "_fetch_json_via_http", return_value={"ok": True}) as mocked_http, + ): + payload = sync_upstream_github.fetch_json("https://api.github.com/repos/test/repo/issues?state=open") + + self.assertEqual(payload, {"ok": True}) + mocked_http.assert_called_once_with("https://api.github.com/repos/test/repo/issues?state=open") + + def test_fetch_json_rate_limit_error_mentions_gh_cli_fallback(self): + rate_limited = HTTPError( + url="https://api.github.com/repos/test/repo/issues", + code=403, + msg="rate limit exceeded", + hdrs=None, + fp=None, + ) + + with ( + patch.object(sync_upstream_github, "has_github_token", return_value=True), + patch("urllib.request.urlopen", side_effect=rate_limited), + ): + with self.assertRaisesRegex(RuntimeError, "log into gh"): + sync_upstream_github.fetch_json("https://api.github.com/repos/test/repo/issues") + + def test_fetch_json_http_retry_recovers_from_transient_500(self): + transient_error = HTTPError( + url="https://api.github.com/repos/test/repo/issues", + code=500, + msg="Internal Server Error", + hdrs=None, + fp=None, + ) + payload = io.BytesIO(b'{"ok": true}') + + with ( + patch.object(sync_upstream_github, "has_github_token", return_value=True), + patch("urllib.request.urlopen", side_effect=[transient_error, nullcontext(payload)]), + patch.object(sync_upstream_github.time, "sleep") as mocked_sleep, + ): + result = sync_upstream_github.fetch_json("https://api.github.com/repos/test/repo/issues") + + self.assertEqual(result, {"ok": True}) + mocked_sleep.assert_called_once_with(1) + + def test_fetch_json_http_retry_raises_runtime_error_after_repeated_5xx(self): + transient_error = HTTPError( + url="https://api.github.com/repos/test/repo/issues", + code=503, + msg="Service Unavailable", + hdrs=None, + fp=None, + ) + + with ( + patch.object(sync_upstream_github, "has_github_token", return_value=True), + patch("urllib.request.urlopen", side_effect=[transient_error] * sync_upstream_github.GH_API_MAX_ATTEMPTS), + patch.object(sync_upstream_github.time, "sleep") as mocked_sleep, + ): + with self.assertRaisesRegex(RuntimeError, "HTTP 503"): + sync_upstream_github.fetch_json("https://api.github.com/repos/test/repo/issues") + + self.assertEqual(mocked_sleep.call_count, sync_upstream_github.GH_API_MAX_ATTEMPTS - 1) + + def test_list_fork_issue_mirrors_filters_by_marker(self): + listed = json.dumps( + [ + { + "number": 12, + "title": "[Upstream #145] Duplicate entity nodes", + "body": "<!-- mirofish-upstream-repo:666ghj/MiroFish -->\n<!-- mirofish-upstream-issue:145 -->\n", + "url": "https://example.test/issues/12", + "state": "OPEN", + }, + { + "number": 13, + "title": "Local issue", + "body": "No marker", + "url": "https://example.test/issues/13", + "state": "OPEN", + }, + ] + ) + with patch.object(sync_upstream_github, "run_gh_command", return_value=listed): + mirrors = sync_upstream_github.list_fork_issue_mirrors("ivanzud/MiroFish", "666ghj/MiroFish") + + self.assertEqual(sorted(mirrors), [145]) + self.assertEqual(mirrors[145]["number"], 12) + + def test_sync_fork_issue_mirror_closes_closed_upstream_issue(self): + issue = { + "number": 145, + "title": "Duplicate entity nodes", + "url": "https://example.test/issues/145", + "state": "closed", + "updated_at": "2026-03-11T15:00:00Z", + "labels": [], + "author": "alice", + "body_excerpt": "Body", + "recent_comments": [], + } + current = { + "number": 8, + "title": "[Upstream #145] Duplicate entity nodes", + "body": sync_upstream_github.build_mirror_issue_body( + "666ghj/MiroFish", + {**issue, "state": "open"}, + ), + "state": "OPEN", + } + + with patch.object(sync_upstream_github, "run_gh_command") as mocked_run: + sync_upstream_github.sync_fork_issue_mirror( + "ivanzud/MiroFish", + "666ghj/MiroFish", + issue, + current, + ) + + self.assertEqual(mocked_run.call_args_list[0].args[0][:4], ["issue", "edit", "-R", "ivanzud/MiroFish"]) + self.assertEqual( + mocked_run.call_args_list[1].args[0], + ["issue", "close", "-R", "ivanzud/MiroFish", "8", "--reason", "completed"], + ) + + def test_sync_fork_issue_mirror_closes_new_closed_issue_after_create(self): + issue = { + "number": 145, + "title": "Duplicate entity nodes", + "url": "https://example.test/issues/145", + "state": "closed", + "updated_at": "2026-03-11T15:00:00Z", + "labels": [], + "author": "alice", + "body_excerpt": "Body", + "recent_comments": [], + } + + with patch.object( + sync_upstream_github, + "run_gh_command", + side_effect=["https://github.com/ivanzud/MiroFish/issues/88\n", ""], + ) as mocked_run: + sync_upstream_github.sync_fork_issue_mirror( + "ivanzud/MiroFish", + "666ghj/MiroFish", + issue, + None, + ) + + self.assertEqual(mocked_run.call_args_list[0].args[0][:4], ["issue", "create", "-R", "ivanzud/MiroFish"]) + self.assertEqual( + mocked_run.call_args_list[1].args[0], + ["issue", "close", "-R", "ivanzud/MiroFish", "88", "--reason", "completed"], + ) + + def test_sync_fork_issue_mirror_reopens_when_upstream_reopens(self): + issue = { + "number": 145, + "title": "Duplicate entity nodes", + "url": "https://example.test/issues/145", + "state": "open", + "updated_at": "2026-03-11T15:00:00Z", + "labels": [], + "author": "alice", + "body_excerpt": "Body", + "recent_comments": [], + } + current = { + "number": 8, + "title": "[Upstream #145] Duplicate entity nodes", + "body": sync_upstream_github.build_mirror_issue_body( + "666ghj/MiroFish", + {**issue, "state": "closed"}, + ), + "state": "CLOSED", + } + + with patch.object(sync_upstream_github, "run_gh_command") as mocked_run: + sync_upstream_github.sync_fork_issue_mirror( + "ivanzud/MiroFish", + "666ghj/MiroFish", + issue, + current, + ) + + self.assertEqual(mocked_run.call_args_list[0].args[0][:4], ["issue", "edit", "-R", "ivanzud/MiroFish"]) + self.assertEqual( + mocked_run.call_args_list[1].args[0], + ["issue", "reopen", "-R", "ivanzud/MiroFish", "8"], + ) + + def test_mirror_issues_to_fork_creates_and_updates_issue_mirrors(self): + issues = [ + { + "number": 145, + "title": "Duplicate entity nodes", + "url": "https://example.test/issues/145", + "state": "open", + "updated_at": "2026-03-11T15:00:00Z", + "labels": [], + "author": "alice", + "body_excerpt": "Body", + "recent_comments": [], + }, + { + "number": 133, + "title": "Backend root confusion", + "url": "https://example.test/issues/133", + "state": "open", + "updated_at": "2026-03-11T15:00:00Z", + "labels": [], + "author": "bob", + "body_excerpt": "Body", + "recent_comments": [], + }, + ] + existing_before = { + 133: { + "number": 7, + "title": "[Upstream #133] stale title", + "body": "stale body", + "url": "https://example.test/issues/7", + "state": "OPEN", + } + } + existing_after = { + 133: { + "number": 7, + "title": "[Upstream #133] Backend root confusion", + "body": sync_upstream_github.build_mirror_issue_body("666ghj/MiroFish", issues[1]), + "url": "https://example.test/issues/7", + "state": "OPEN", + }, + 145: { + "number": 8, + "title": "[Upstream #145] Duplicate entity nodes", + "body": sync_upstream_github.build_mirror_issue_body("666ghj/MiroFish", issues[0]), + "url": "https://example.test/issues/8", + "state": "OPEN", + }, + } + + with patch.object( + sync_upstream_github, + "list_fork_issue_mirrors", + side_effect=[existing_before, existing_after], + ), patch.object(sync_upstream_github, "run_gh_command") as mocked_run: + mirrored = sync_upstream_github.mirror_issues_to_fork( + "ivanzud/MiroFish", + "666ghj/MiroFish", + issues, + ) + + create_call = mocked_run.call_args_list[0] + edit_call = mocked_run.call_args_list[1] + self.assertIn("issue", create_call.args[0]) + self.assertIn("create", create_call.args[0]) + self.assertIn("[Upstream #145] Duplicate entity nodes", create_call.args[0]) + self.assertIn("issue", edit_call.args[0]) + self.assertIn("edit", edit_call.args[0]) + self.assertEqual(mirrored[0]["fork_issue_number"], 8) + self.assertEqual(mirrored[1]["fork_issue_number"], 7) + + def test_main_reuses_recent_cached_snapshot_on_rate_limit(self): + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) / "state.json" + summary_path = Path(tmpdir) / "summary.md" + coverage_path = Path(tmpdir) / "coverage.json" + cached_payload = { + "repo": "666ghj/MiroFish", + "state": "open", + "captured_at": "2026-03-11T08:30:00+00:00", + "fork_remote": "origin", + "mirror_issues_repo": "ivanzud/MiroFish", + "issues": [ + { + "number": 1, + "title": "Issue", + "url": "https://example.test/issues/1", + "state": "open", + "created_at": "2026-03-10T00:00:00Z", + "updated_at": "2026-03-11T00:00:00Z", + "closed_at": None, + "labels": [], + "author": "alice", + "body_excerpt": "Issue body", + "comment_count": 0, + "recent_comments": [], + "fork_issue_mirrored": True, + "fork_issue_number": 99, + "fork_issue_url": "https://example.test/issues/99", + } + ], + "pull_requests": [ + { + "number": 2, + "title": "PR", + "url": "https://example.test/pull/2", + "state": "open", + "created_at": "2026-03-10T00:00:00Z", + "updated_at": "2026-03-11T00:00:00Z", + "closed_at": None, + "merged_at": None, + "head": "feature", + "head_sha": "abc123", + "head_repo": "fork/repo", + "head_clone_url": "https://example.test/fork/repo.git", + "base": "main", + "base_repo": "666ghj/MiroFish", + "draft": False, + "mergeable_state": "clean", + "labels": [], + "author": "bob", + "body_excerpt": "PR body", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": True, + "fork_mirror_ref": "origin/mirror/upstream-pr-2", + } + ], + } + output_path.write_text(json.dumps(cached_payload), encoding="utf-8") + coverage_path.write_text( + json.dumps( + { + "issues": [ + {"number": 1, "status": "covered", "summary": "Issue handled locally"} + ], + "pull_requests": [ + {"number": 2, "status": "landed", "summary": "PR landed locally"} + ], + } + ), + encoding="utf-8", + ) + + stderr = io.StringIO() + stdout = io.StringIO() + rate_limit_error = RuntimeError("GitHub API rate limit exceeded. Set GITHUB_TOKEN or GH_TOKEN.") + + with ( + patch.object( + sync_upstream_github, + "build_parser", + return_value=sync_upstream_github.build_parser(), + ), + patch.object( + sync_upstream_github.sys, + "argv", + [ + "sync_upstream_github.py", + "--repo", + "666ghj/MiroFish", + "--state", + "open", + "--output", + str(output_path), + "--summary", + str(summary_path), + "--fork-remote", + "origin", + "--mirror-issues-repo", + "ivanzud/MiroFish", + "--coverage-map", + str(coverage_path), + ], + ), + patch.object(sync_upstream_github, "github_api_paginated", side_effect=rate_limit_error), + patch("sys.stderr", stderr), + patch("sys.stdout", stdout), + patch.object(sync_upstream_github, "datetime") as mocked_datetime, + ): + mocked_datetime.now.return_value = __import__("datetime").datetime(2026, 3, 11, 9, 0, tzinfo=__import__("datetime").timezone.utc) + mocked_datetime.fromisoformat = __import__("datetime").datetime.fromisoformat + result = sync_upstream_github.main() + + self.assertEqual(result, 0) + self.assertTrue(summary_path.exists()) + self.assertIn("reusing fresh cached snapshot", stderr.getvalue()) + self.assertIn("Reused cached snapshot", stdout.getvalue()) + summary_text = summary_path.read_text(encoding="utf-8") + self.assertIn("2026-03-11T08:30:00+00:00", summary_text) + self.assertIn("local coverage [landed]: PR landed locally", summary_text) + self.assertIn("Mirrored in `ivanzud/MiroFish`: `1` of `1` issues", summary_text) + refreshed_payload = json.loads(output_path.read_text(encoding="utf-8")) + self.assertEqual(refreshed_payload["mirror_issues_repo"], "ivanzud/MiroFish") + self.assertEqual(refreshed_payload["refreshed_at"], "2026-03-11T08:30:00+00:00") + self.assertNotIn("_cache_path", refreshed_payload) + self.assertEqual(refreshed_payload["pull_requests"][0]["local_coverage"]["status"], "landed") + + def test_main_writes_backward_compatible_generated_at_field(self): + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) / "state.json" + summary_path = Path(tmpdir) / "summary.md" + pr_payload = { + "number": 2, + "title": "PR", + "html_url": "https://example.test/pull/2", + "state": "open", + "created_at": "2026-03-10T00:00:00Z", + "updated_at": "2026-03-11T00:00:00Z", + "closed_at": None, + "merged_at": None, + "head": { + "ref": "feature", + "sha": "abc123", + "repo": { + "full_name": "fork/repo", + "clone_url": "https://example.test/fork/repo.git", + }, + }, + "base": { + "ref": "main", + "repo": {"full_name": "666ghj/MiroFish"}, + }, + "draft": False, + "mergeable_state": "clean", + "labels": [], + "user": {"login": "bob"}, + "body": "PR body", + "comments": 0, + "review_comments": 0, + } + + with ( + patch.object( + sync_upstream_github.sys, + "argv", + [ + "sync_upstream_github.py", + "--repo", + "666ghj/MiroFish", + "--state", + "open", + "--output", + str(output_path), + "--summary", + str(summary_path), + ], + ), + patch.object( + sync_upstream_github, + "github_api_paginated", + side_effect=[ + [ + { + "number": 1, + "title": "Issue", + "html_url": "https://example.test/issues/1", + "state": "open", + "created_at": "2026-03-10T00:00:00Z", + "updated_at": "2026-03-11T00:00:00Z", + "closed_at": None, + "labels": [], + "user": {"login": "alice"}, + "body": "Issue body", + "comments": 0, + } + ], + [pr_payload], + ], + ), + patch.object(sync_upstream_github, "hydrate_pull_requests", return_value=[pr_payload]), + patch.object(sync_upstream_github, "datetime") as mocked_datetime, + ): + mocked_datetime.now.return_value = __import__("datetime").datetime(2026, 3, 11, 9, 0, tzinfo=__import__("datetime").timezone.utc) + mocked_datetime.fromisoformat = __import__("datetime").datetime.fromisoformat + result = sync_upstream_github.main() + + self.assertEqual(result, 0) + payload = json.loads(output_path.read_text(encoding="utf-8")) + self.assertEqual(payload["captured_at"], "2026-03-11T09:00:00+00:00") + self.assertEqual(payload["generated_at"], "2026-03-11T09:00:00+00:00") + self.assertEqual(payload["refreshed_at"], "2026-03-11T09:00:00+00:00") + self.assertIn("2026-03-11T09:00:00+00:00", summary_path.read_text(encoding="utf-8")) + + def test_main_accepts_legacy_positional_repo_argument(self): + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) / "state.json" + summary_path = Path(tmpdir) / "summary.md" + pr_payload = { + "number": 2, + "title": "PR", + "html_url": "https://example.test/pull/2", + "state": "open", + "created_at": "2026-03-10T00:00:00Z", + "updated_at": "2026-03-11T00:00:00Z", + "closed_at": None, + "merged_at": None, + "head": { + "ref": "feature", + "sha": "abc123", + "repo": { + "full_name": "fork/repo", + "clone_url": "https://example.test/fork/repo.git", + }, + }, + "base": { + "ref": "main", + "repo": {"full_name": "666ghj/MiroFish"}, + }, + "draft": False, + "mergeable_state": "clean", + "labels": [], + "user": {"login": "bob"}, + "body": "PR body", + "comments": 0, + "review_comments": 0, + } + + with ( + patch.object( + sync_upstream_github.sys, + "argv", + [ + "sync_upstream_github.py", + "666ghj/MiroFish", + "--state", + "open", + "--output", + str(output_path), + "--summary", + str(summary_path), + ], + ), + patch.object( + sync_upstream_github, + "github_api_paginated", + side_effect=[ + [ + { + "number": 1, + "title": "Issue", + "html_url": "https://example.test/issues/1", + "state": "open", + "created_at": "2026-03-10T00:00:00Z", + "updated_at": "2026-03-11T00:00:00Z", + "closed_at": None, + "labels": [], + "user": {"login": "alice"}, + "body": "Issue body", + "comments": 0, + } + ], + [pr_payload], + ], + ), + patch.object(sync_upstream_github, "hydrate_pull_requests", return_value=[pr_payload]), + patch.object(sync_upstream_github, "datetime") as mocked_datetime, + ): + mocked_datetime.now.return_value = __import__("datetime").datetime( + 2026, 3, 11, 9, 0, tzinfo=__import__("datetime").timezone.utc + ) + mocked_datetime.fromisoformat = __import__("datetime").datetime.fromisoformat + result = sync_upstream_github.main() + + self.assertEqual(result, 0) + payload = json.loads(output_path.read_text(encoding="utf-8")) + self.assertEqual(payload["repo"], "666ghj/MiroFish") + + def test_main_mirrors_missing_pr_refs_before_compaction(self): + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) / "state.json" + summary_path = Path(tmpdir) / "summary.md" + pr_payload = { + "number": 155, + "title": "PR", + "html_url": "https://example.test/pull/155", + "state": "open", + "created_at": "2026-03-10T00:00:00Z", + "updated_at": "2026-03-11T00:00:00Z", + "closed_at": None, + "merged_at": None, + "head": { + "ref": "feature", + "sha": "abc123", + "repo": { + "full_name": "fork/repo", + "clone_url": "https://example.test/fork/repo.git", + }, + }, + "base": { + "ref": "main", + "repo": {"full_name": "666ghj/MiroFish"}, + }, + "draft": False, + "mergeable_state": "clean", + "labels": [], + "user": {"login": "bob"}, + "body": "PR body", + "comments": 0, + "review_comments": 0, + } + + with ( + patch.object( + sync_upstream_github.sys, + "argv", + [ + "sync_upstream_github.py", + "--repo", + "666ghj/MiroFish", + "--state", + "open", + "--output", + str(output_path), + "--summary", + str(summary_path), + "--fork-remote", + "origin", + ], + ), + patch.object( + sync_upstream_github, + "github_api_paginated", + side_effect=[ + [ + { + "number": 1, + "title": "Issue", + "html_url": "https://example.test/issues/1", + "state": "open", + "created_at": "2026-03-10T00:00:00Z", + "updated_at": "2026-03-11T00:00:00Z", + "closed_at": None, + "labels": [], + "user": {"login": "alice"}, + "body": "Issue body", + "comments": 0, + } + ], + [pr_payload], + ], + ), + patch.object(sync_upstream_github, "hydrate_pull_requests", return_value=[pr_payload]), + patch.object(sync_upstream_github, "list_mirrored_pull_request_numbers", return_value=set()), + patch.object(sync_upstream_github, "mirror_pull_request_refs", return_value={155}) as mirrored, + patch.object(sync_upstream_github, "compact_pull_requests", return_value=[]) as compacted, + patch.object(sync_upstream_github, "datetime") as mocked_datetime, + ): + mocked_datetime.now.return_value = __import__("datetime").datetime( + 2026, 3, 11, 9, 0, tzinfo=__import__("datetime").timezone.utc + ) + mocked_datetime.fromisoformat = __import__("datetime").datetime.fromisoformat + result = sync_upstream_github.main() + + self.assertEqual(result, 0) + mirrored.assert_called_once_with("666ghj/MiroFish", "origin", [pr_payload], set()) + compacted_args = compacted.call_args + self.assertEqual(compacted_args.args[0], [pr_payload]) + self.assertEqual(compacted_args.args[1], {155}) + self.assertEqual(compacted_args.args[2], "origin") + self.assertEqual( + compacted_args.kwargs["max_workers"], + sync_upstream_github.DEFAULT_MAX_WORKERS, + ) + self.assertIsInstance(compacted_args.kwargs["coverage_map"], dict) + + def test_main_reuses_recent_cached_snapshot_when_pr_hydration_rate_limited(self): + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) / "state.json" + summary_path = Path(tmpdir) / "summary.md" + cached_payload = { + "repo": "666ghj/MiroFish", + "state": "open", + "captured_at": "2026-03-11T08:30:00+00:00", + "fork_remote": "origin", + "issues": [ + { + "number": 1, + "title": "Issue", + "url": "https://example.test/issues/1", + "state": "open", + "created_at": "2026-03-10T00:00:00Z", + "updated_at": "2026-03-11T00:00:00Z", + "closed_at": None, + "labels": [], + "author": "alice", + "body_excerpt": "Issue body", + "comment_count": 0, + "recent_comments": [], + } + ], + "pull_requests": [ + { + "number": 2, + "title": "PR", + "url": "https://example.test/pull/2", + "state": "open", + "created_at": "2026-03-10T00:00:00Z", + "updated_at": "2026-03-11T00:00:00Z", + "closed_at": None, + "merged_at": None, + "head": "feature", + "head_sha": "abc123", + "head_repo": "fork/repo", + "head_clone_url": "https://example.test/fork/repo.git", + "base": "main", + "base_repo": "666ghj/MiroFish", + "draft": False, + "mergeable_state": "clean", + "labels": [], + "author": "bob", + "body_excerpt": "PR body", + "comment_count": 0, + "review_comment_count": 0, + "recent_comments": [], + "fork_mirrored": True, + "fork_mirror_ref": "origin/mirror/upstream-pr-2", + } + ], + } + output_path.write_text(json.dumps(cached_payload), encoding="utf-8") + + stderr = io.StringIO() + stdout = io.StringIO() + rate_limit_error = RuntimeError("GitHub API rate limit exceeded while hydrating pull request details.") + + with ( + patch.object( + sync_upstream_github.sys, + "argv", + [ + "sync_upstream_github.py", + "--repo", + "666ghj/MiroFish", + "--state", + "open", + "--output", + str(output_path), + "--summary", + str(summary_path), + "--fork-remote", + "origin", + ], + ), + patch.object( + sync_upstream_github, + "github_api_paginated", + side_effect=[ + [ + { + "number": 1, + "title": "Issue title", + "html_url": "https://example.test/issues/1", + "state": "open", + "created_at": "2026-03-10T00:00:00Z", + "updated_at": "2026-03-11T00:00:00Z", + "closed_at": None, + "labels": [], + "user": {"login": "alice"}, + "body": "Issue body", + "comments": 0, + } + ], + [ + { + "number": 2, + "title": "PR title", + "html_url": "https://example.test/pull/2", + "state": "open", + "created_at": "2026-03-10T00:00:00Z", + "updated_at": "2026-03-11T00:00:00Z", + "closed_at": None, + "merged_at": None, + "head": {"ref": "feature"}, + "base": {"ref": "main"}, + "comments": 0, + "review_comments": 0, + } + ], + ], + ), + patch.object( + sync_upstream_github, + "compact_issues", + return_value=cached_payload["issues"], + ), + patch.object( + sync_upstream_github, + "hydrate_pull_requests", + side_effect=rate_limit_error, + ), + patch("sys.stderr", stderr), + patch("sys.stdout", stdout), + patch.object(sync_upstream_github, "datetime") as mocked_datetime, + ): + mocked_datetime.now.return_value = __import__("datetime").datetime( + 2026, 3, 11, 9, 0, tzinfo=__import__("datetime").timezone.utc + ) + mocked_datetime.fromisoformat = __import__("datetime").datetime.fromisoformat + result = sync_upstream_github.main() + + self.assertEqual(result, 0) + self.assertTrue(summary_path.exists()) + self.assertIn("reusing fresh cached snapshot", stderr.getvalue()) + self.assertIn("Reused cached snapshot", stdout.getvalue()) + self.assertIn("captured_at=2026-03-11T08:30:00+00:00", stderr.getvalue()) + refreshed_payload = json.loads(output_path.read_text(encoding="utf-8")) + self.assertEqual(refreshed_payload["refreshed_at"], "2026-03-11T08:30:00+00:00") + self.assertNotIn("_cache_path", refreshed_payload) + + def test_repo_lock_rejects_overlapping_run(self): + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) / "state.json" + with sync_upstream_github.repo_lock(output_path, "666ghj/MiroFish"): + with self.assertRaisesRegex(RuntimeError, "already refreshing 666ghj/MiroFish"): + with sync_upstream_github.repo_lock(output_path, "666ghj/MiroFish"): + pass + + def test_repo_lock_waits_for_overlapping_run_when_timeout_allows(self): + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) / "state.json" + release_lock = threading.Event() + acquired_lock = threading.Event() + + def hold_lock() -> None: + with sync_upstream_github.repo_lock(output_path, "666ghj/MiroFish"): + acquired_lock.set() + release_lock.wait(timeout=1) + + worker = threading.Thread(target=hold_lock) + worker.start() + self.assertTrue(acquired_lock.wait(timeout=1)) + + started = time.monotonic() + try: + release_lock.set() + with sync_upstream_github.repo_lock( + output_path, + "666ghj/MiroFish", + wait_timeout=0.5, + poll_interval=0.01, + ): + pass + finally: + worker.join(timeout=1) + + self.assertGreaterEqual(time.monotonic() - started, 0.0) + + def test_repo_lock_wait_timeout_mentions_lock_wait_flag(self): + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) / "state.json" + with sync_upstream_github.repo_lock(output_path, "666ghj/MiroFish"): + with self.assertRaisesRegex(RuntimeError, "--lock-wait-seconds"): + with sync_upstream_github.repo_lock( + output_path, + "666ghj/MiroFish", + wait_timeout=0.02, + poll_interval=0.01, + ): + pass + + def test_main_raises_on_rate_limit_when_cache_is_stale(self): + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) / "state.json" + output_path.write_text( + json.dumps( + { + "repo": "666ghj/MiroFish", + "state": "open", + "captured_at": "2026-03-01T08:30:00+00:00", + "issues": [], + "pull_requests": [], + } + ), + encoding="utf-8", + ) + + rate_limit_error = RuntimeError("GitHub API rate limit exceeded. Set GITHUB_TOKEN or GH_TOKEN.") + with ( + patch.object( + sync_upstream_github.sys, + "argv", + [ + "sync_upstream_github.py", + "--repo", + "666ghj/MiroFish", + "--state", + "open", + "--output", + str(output_path), + "--summary", + str(Path(tmpdir) / "summary.md"), + ], + ), + patch.object(sync_upstream_github, "github_api_paginated", side_effect=rate_limit_error), + patch.object(sync_upstream_github, "datetime") as mocked_datetime, + ): + mocked_datetime.now.return_value = __import__("datetime").datetime(2026, 3, 11, 9, 0, tzinfo=__import__("datetime").timezone.utc) + mocked_datetime.fromisoformat = __import__("datetime").datetime.fromisoformat + with self.assertRaisesRegex(RuntimeError, "rate limit exceeded"): + sync_upstream_github.main() + + def test_fetch_json_via_gh_retries_transient_failures(self): + transient = subprocess.CalledProcessError( + 1, + ["gh", "api", "/repos/test/repo/pulls/101"], + stderr="HTTP 502 from GitHub", + ) + success = type("Completed", (), {"stdout": '{"ok": true}'})() + + with ( + patch.object(sync_upstream_github.subprocess, "run", side_effect=[transient, success]) as mocked, + patch.object(sync_upstream_github.time, "sleep") as mocked_sleep, + ): + payload = sync_upstream_github.fetch_json_via_gh("https://api.github.com/repos/test/repo/pulls/101") + + self.assertEqual(payload, {"ok": True}) + self.assertEqual(mocked.call_count, 2) + mocked_sleep.assert_called_once_with(1) + self.assertEqual(mocked.call_args.kwargs["timeout"], sync_upstream_github.REQUEST_TIMEOUT) + + def test_fetch_json_via_gh_sets_subprocess_timeout(self): + success = type("Completed", (), {"stdout": '{"ok": true}'})() + + with patch.object(sync_upstream_github.subprocess, "run", return_value=success) as mocked: + payload = sync_upstream_github.fetch_json_via_gh("https://api.github.com/repos/test/repo/pulls/101") + + self.assertEqual(payload, {"ok": True}) + self.assertEqual(mocked.call_args.kwargs["timeout"], sync_upstream_github.REQUEST_TIMEOUT) + + def test_fetch_json_via_http_sets_request_timeout(self): + response = type("Response", (), {"__enter__": lambda self: self, "__exit__": lambda *args: None, "read": lambda self: b'{"ok": true}'})() + + with patch("urllib.request.urlopen", return_value=response) as mocked: + payload = sync_upstream_github._fetch_json_via_http("https://api.github.com/repos/test/repo/issues") + + self.assertEqual(payload, {"ok": True}) + self.assertEqual(mocked.call_args.kwargs["timeout"], sync_upstream_github.REQUEST_TIMEOUT) + + def test_fetch_json_via_http_wraps_timeout(self): + with patch("urllib.request.urlopen", side_effect=TimeoutError("timed out")): + with self.assertRaisesRegex(RuntimeError, "timed out after"): + sync_upstream_github._fetch_json_via_http("https://api.github.com/repos/test/repo/issues") + + def test_github_api_paginated_collects_multiple_pages(self): + responses = [ + [{"number": n} for n in range(1, 101)], + [{"number": n} for n in range(101, 106)], + ] + + with patch.object(sync_upstream_github, "github_api", side_effect=responses) as mocked: + items = sync_upstream_github.github_api_paginated("/repos/test/repo/issues", {"state": "all"}, limit=105) + + self.assertEqual(items[0], {"number": 1}) + self.assertEqual(items[-1], {"number": 105}) + self.assertEqual(len(items), 105) + self.assertEqual(mocked.call_count, 2) + self.assertEqual(mocked.call_args_list[0].args[1]["page"], 1) + self.assertEqual(mocked.call_args_list[1].args[1]["page"], 2) + + def test_parallel_ordered_map_preserves_input_order(self): + items = [1, 2, 3, 4] + + results = sync_upstream_github.parallel_ordered_map( + items, + lambda item: {"item": item, "square": item * item}, + max_workers=3, + ) + + self.assertEqual( + results, + [ + {"item": 1, "square": 1}, + {"item": 2, "square": 4}, + {"item": 3, "square": 9}, + {"item": 4, "square": 16}, + ], + ) + + def test_hydrate_pull_requests_fetches_detail_payloads(self): + with patch.object( + sync_upstream_github, + "github_api", + side_effect=[ + {"number": 101, "mergeable_state": "clean"}, + {"number": 102, "mergeable_state": "dirty"}, + ], + ) as mocked: + payload = sync_upstream_github.hydrate_pull_requests( + "test-owner", + "test-repo", + [{"number": 101}, {"number": 102}], + max_workers=2, + ) + + self.assertEqual( + payload, + [ + {"number": 101, "mergeable_state": "clean"}, + {"number": 102, "mergeable_state": "dirty"}, + ], + ) + self.assertEqual(mocked.call_args_list[0].args, ("/repos/test-owner/test-repo/pulls/101", {})) + self.assertEqual(mocked.call_args_list[1].args, ("/repos/test-owner/test-repo/pulls/102", {})) + + def test_compact_helpers_delegate_parallel_work_with_requested_worker_cap(self): + issue_items = [{"number": 1}, {"number": 2}] + pr_items = [{"number": 101}, {"number": 102}] + + with ( + patch.object( + sync_upstream_github, + "parallel_ordered_map", + side_effect=[ + [{"number": 1, "kind": "issue"}, {"number": 2, "kind": "issue"}], + [{"number": 101, "kind": "pr"}, {"number": 102, "kind": "pr"}], + [{"number": 101, "title": "PR 101"}, {"number": 102, "title": "PR 102"}], + ], + ) as mocked_parallel, + patch.object(sync_upstream_github, "compact_pr", side_effect=lambda item, mirrored, remote: item), + ): + issues = sync_upstream_github.compact_issues(issue_items, max_workers=5) + hydrated = sync_upstream_github.hydrate_pull_requests( + "test-owner", + "test-repo", + pr_items, + max_workers=4, + ) + prs = sync_upstream_github.compact_pull_requests( + hydrated, + mirrored_pr_numbers={101}, + fork_remote="origin", + max_workers=3, + ) + + self.assertEqual(issues, [{"number": 1, "kind": "issue"}, {"number": 2, "kind": "issue"}]) + self.assertEqual(hydrated, [{"number": 101, "kind": "pr"}, {"number": 102, "kind": "pr"}]) + self.assertEqual(prs, [{"number": 101, "title": "PR 101"}, {"number": 102, "title": "PR 102"}]) + self.assertEqual(mocked_parallel.call_args_list[0].kwargs["max_workers"], 5) + self.assertEqual(mocked_parallel.call_args_list[1].kwargs["max_workers"], 4) + self.assertEqual(mocked_parallel.call_args_list[2].kwargs["max_workers"], 3) + + def test_compact_records_include_state_fields(self): + with patch.object( + sync_upstream_github, + "fetch_recent_comments", + side_effect=[ + [{"author": "carol", "body_excerpt": "issue comment"}], + [{"author": "dave", "body_excerpt": "pr comment"}], + ], + ): + issue = sync_upstream_github.compact_issue( + { + "number": 10, + "title": "Issue title", + "html_url": "https://example.test/issues/10", + "state": "closed", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-02T00:00:00Z", + "closed_at": "2026-01-03T00:00:00Z", + "labels": [{"name": "bug"}], + "user": {"login": "alice"}, + "body": "Issue body", + "comments": 1, + "comments_url": "https://api.github.com/repos/test/repo/issues/10/comments", + } + ) + pr = sync_upstream_github.compact_pr( + { + "number": 11, + "title": "PR title", + "html_url": "https://example.test/pull/11", + "state": "closed", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-02T00:00:00Z", + "closed_at": "2026-01-03T00:00:00Z", + "merged_at": "2026-01-03T00:00:01Z", + "head": { + "ref": "feature", + "sha": "abc123", + "repo": { + "full_name": "contrib/test-repo", + "clone_url": "https://github.com/contrib/test-repo.git", + }, + }, + "base": { + "ref": "main", + "repo": {"full_name": "test-owner/test-repo"}, + }, + "draft": False, + "mergeable_state": "clean", + "labels": [{"name": "enhancement"}], + "user": {"login": "bob"}, + "body": "PR body", + "comments": 1, + "comments_url": "https://api.github.com/repos/test/repo/issues/11/comments", + "review_comments": 2, + }, + mirrored_pr_numbers={11}, + fork_remote="origin", + ) + + self.assertEqual(issue["state"], "closed") + self.assertEqual(issue["closed_at"], "2026-01-03T00:00:00Z") + self.assertEqual(issue["body_excerpt"], "Issue body") + self.assertEqual(issue["comment_count"], 1) + self.assertEqual(issue["recent_comments"], [{"author": "carol", "body_excerpt": "issue comment"}]) + self.assertEqual(pr["state"], "closed") + self.assertEqual(pr["merged_at"], "2026-01-03T00:00:01Z") + self.assertEqual(pr["head"], "feature") + self.assertEqual(pr["head_sha"], "abc123") + self.assertEqual(pr["head_repo"], "contrib/test-repo") + self.assertEqual(pr["head_clone_url"], "https://github.com/contrib/test-repo.git") + self.assertEqual(pr["base"], "main") + self.assertEqual(pr["base_repo"], "test-owner/test-repo") + self.assertEqual(pr["mergeable_state"], "clean") + self.assertEqual(pr["labels"], ["enhancement"]) + self.assertEqual(pr["body_excerpt"], "PR body") + self.assertEqual(pr["comment_count"], 1) + self.assertEqual(pr["review_comment_count"], 2) + self.assertEqual(pr["recent_comments"], [{"author": "dave", "body_excerpt": "pr comment"}]) + self.assertTrue(pr["fork_mirrored"]) + self.assertEqual(pr["fork_mirror_ref"], "origin/mirror/upstream-pr-11") + + +if __name__ == "__main__": + unittest.main()