fix(topic): 深话题 analyzer 失败退避 + 富化失败原因可见化(不泄漏对话原文) #11825
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Analyze | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| branches: [main] | |
| workflow_dispatch: | |
| # Static-check jobs only need to read the checked-out source. | |
| permissions: | |
| contents: read | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Five parallel jobs, split by concern so a failing check name points at a | |
| # category (expand it to see which step is red) instead of one giant title | |
| # enumerating every sub-check: | |
| # | |
| # Ruff → the ruff linter (its own deps: uv + ruff) | |
| # Code safety → runtime hazards in the backend (blocking-in-async, | |
| # banned imports, startup import cost) | |
| # Prompts & i18n → LLM-call discipline + localization/docstring rules | |
| # API & layering → HTTP/route + module-layer architecture rules | |
| # Core package contracts → the main_logic/core mixin/facade structural gate | |
| # | |
| # All run in parallel, all block merge, each is independently re-runnable. Only | |
| # the diff-based steps (i18n-sync, docstring-cjk) need full history; the rest | |
| # run on a shallow checkout. | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| jobs: | |
| ruff: | |
| name: Ruff | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: Set up Python 3.11 | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.11' | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@v4 | |
| - name: Install ruff | |
| # ruff is the only dev dep we need; a full uv sync pulls heavy native | |
| # extensions (playwright, numpy, etc.) unnecessary for static checks. | |
| run: uv tool install ruff==0.15.4 | |
| - name: ruff check (incl. ASYNC210/220/221/222/251) | |
| run: ruff check . | |
| code-safety: | |
| name: Code safety | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: Set up Python 3.11 | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.11' | |
| - name: Forbid blocking calls in async def bodies | |
| # Custom AST checker, broader than ruff's ASYNC* rules: | |
| # * gaps flake8-async doesn't catch: Thread/Process.join, | |
| # queue.Queue.get, raw socket recv/accept/connect | |
| # * extra blocking stdlib/3p calls: PIL.Image.open, | |
| # pyautogui.screenshot, Fernet encrypt/decrypt, shutil.*, | |
| # json.load, plus bare-import forms (sleep, rmtree, urlopen) | |
| # * depth-1 transitive: a sync helper whose body hits any of the | |
| # above, called directly from async, is also flagged. | |
| run: python scripts/check_async_blocking.py | |
| - name: Forbid loguru / structlog / logbook imports | |
| # Logging is unified through utils.logger_config (RobustLoggerConfig). | |
| # Re-introducing a third-party logging frontend fragments the surface | |
| # (formatter, sinks, file naming, multi-process behaviour) and breaks | |
| # plugin/main parity. This check fails the build on any such import. | |
| run: python scripts/check_no_loguru.py | |
| - name: Forbid `tkinter` imports | |
| # Architecture rule: frontend/backend are split, ALL GUI operations | |
| # belong on the Electron frontend (Node.js renderer/main), not the | |
| # Python backend. The backend is a headless HTTP/async service — | |
| # it must stay relocatable (remote deployment, headless CI) and | |
| # not own a Tcl/Tk event loop. Past incident: PR #1014's Windows | |
| # tk screenshot overlay crashed the whole app under Nuitka builds | |
| # without `--enable-plugin=tk-inter` (SystemExit from tk.Tk() | |
| # escaped the asyncio worker, killed uvicorn). For dialogs use | |
| # Electron's `dialog` (or platform-native shell bridges in | |
| # storage_location_router.py); for screenshot framing use | |
| # Electron's desktopCapturer region path. | |
| run: python scripts/check_no_tkinter.py | |
| - name: Forbid `temperature=` kwargs on LLM client calls (memory + utils) | |
| # Project policy: do NOT pass `temperature=` to create_chat_llm / | |
| # ChatOpenAI / wrappers in memory_server.py + memory/ + utils/. The | |
| # default (None) omits the field — required for o1/o3/gpt-5-thinking/ | |
| # Claude extended-thinking, and avoids per-call-site temperature drift | |
| # across memory tasks. See memory/__init__.py and .agent/rules/ | |
| # neko-guide.md for the rationale. | |
| run: python scripts/check_no_temperature.py | |
| - name: Keep heavy SDKs off the startup import chain (lazy-import contract) | |
| # Merged production mode imports the app tree serially before any port | |
| # binds, so a module-scope `import openai` (etc.) slows EVERY launch, | |
| # silently — reference incident: #1496 cut main_server import to ~0.6s, | |
| # openai 2.x types growth quietly ate it back to ~2.1s within 6 weeks. | |
| # Banned-at-module-scope list (openai/anthropic/bs4/bilibili_api/ | |
| # google.genai/translatepy/dashscope/...) lives in the script; the | |
| # sanctioned pattern is in-function import + utils/module_warmup.py | |
| # background pre-import after ready. `# noqa: STARTUP_LAZY_IMPORT` | |
| # opts out with justification. | |
| run: python scripts/check_startup_import_lazy.py | |
| prompts-i18n: | |
| name: Prompts & i18n | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - uses: actions/checkout@v5 | |
| with: | |
| # i18n-sync / docstring-cjk are diff-based and need full history to | |
| # diff against the merge-base of origin/main. The other steps don't | |
| # care, but fetch-depth: 0 has negligible cost on this repo. | |
| # | |
| # The checkout@v5 bump also fixes a credential-handling regression the | |
| # old @v4 tripped against the runner's git 2.54 on the full-history | |
| # fetch ("could not read Username", git exit 128). | |
| fetch-depth: 0 | |
| - name: Set up Python 3.11 | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.11' | |
| - name: Enforce prompt-i18n conventions (inline-EN-only + multilang-in-config) | |
| # Two rules in one walker: | |
| # - INLINE_PROMPT_NON_EN: any string at an LLM call site or in a | |
| # module-level *PROMPT/*INSTRUCTION/*SYSTEM constant must be | |
| # English-bodied (CJK ratio <30%). Embedded short examples in | |
| # CJK are allowed under the threshold. | |
| # - I18N_NOT_IN_CONFIG: multi-language dicts (≥2 lang keys, must | |
| # include 'en') belong in config/prompts_*.py, not regular code. | |
| # Per-line `# noqa: <CODE>` suppression supported. See PR #974 for | |
| # the reference incident that motivated this lint. | |
| run: python scripts/check_prompt_hygiene.py | |
| - name: Enforce LLM budget/timeout discipline (output budget + timeout + input budget) | |
| # Project policy: every LLM call must be bounded. | |
| # - LLM_OUTPUT_BUDGET: every create_chat_llm() / ChatOpenAI() | |
| # construction must set a token budget (max_completion_tokens= / | |
| # max_tokens=) AND a timeout=. Without a budget the reply can run | |
| # away (cost + latency + context blow-up); without a timeout a hung | |
| # upstream wedges the async pipeline — neither has a safe default in | |
| # utils/llm_client.py. Sites that set budget/timeout per-call (via | |
| # invoke/ainvoke/..(**overrides)) opt out with a justified | |
| # `# noqa: LLM_OUTPUT_BUDGET`. | |
| # - LLM_INPUT_BUDGET (heuristic): every dynamic LLM call must be | |
| # input-budget-aware (truncate_to_tokens / *_MAX_TOKENS in the | |
| # enclosing function). Intentionally-uncapped sites (user-config / | |
| # OS window title etc., cf. docs/design/llm-prompt-budget.md §6) | |
| # opt out with `# noqa: LLM_INPUT_BUDGET`. | |
| # See docs/design/llm-prompt-budget.md for the full contract. | |
| run: python scripts/check_llm_budget.py | |
| - name: Verify i18n locale files move in lockstep (PR-only) | |
| # Diff-based: when ANY static/locales/*.json or | |
| # frontend/plugin-manager/src/i18n/locales/*.ts changes, ALL files | |
| # in the group must change AND every hunk must occupy the same line | |
| # ranges across all languages. Skipped on direct push to main — | |
| # there's nothing to diff against. | |
| if: github.event_name == 'pull_request' | |
| env: | |
| # Indirection instead of inlining ${{ github.base_ref }} into the | |
| # shell line: with the `pull_request: branches: [main]` trigger the | |
| # ref is always `main`, but zizmor flags the inline form as | |
| # template-injection, and env-var expansion is inert either way. | |
| BASE_REF: ${{ github.base_ref }} | |
| run: python scripts/check_i18n_sync.py --base "origin/${BASE_REF}" | |
| - name: Forbid CJK in new/modified Python docstrings (PR-only) | |
| # Convention: docstrings in the main program are written in English. | |
| # The repo carries a large legacy stock of CJK docstrings, so this is | |
| # a diff-ratchet: only docstrings whose line span overlaps lines | |
| # added/modified in this PR (vs the merge-base) are checked — touch | |
| # it, translate it. plugin/ and local_server/ are policy-exempt | |
| # (plugin internals follow their own conventions; local_server is a | |
| # semi-independent unit). `--full` exists for local migration sweeps. | |
| # Suppress a single docstring with `# noqa: DOCSTRING_CJK` (e.g. | |
| # fixtures whose CJK content is itself under test). Skipped on direct | |
| # push to main — nothing to diff against. | |
| if: github.event_name == 'pull_request' | |
| env: | |
| # Same zizmor template-injection indirection as the i18n-sync step. | |
| BASE_REF: ${{ github.base_ref }} | |
| run: python scripts/check_docstring_no_cjk.py --base "origin/${BASE_REF}" | |
| - name: Require zh-TW on new localized prompt dicts (PR-only) | |
| # A dict under config/prompts/ with an 'en' key plus 'zh'/'zh-CN' is a | |
| # localized prompt table, and `_loc` falls back to 'en' rather than | |
| # 'zh' on a missing key — so a table without 'zh-TW' serves Traditional | |
| # Chinese users an English prompt. 339 existing tables are still short | |
| # one (the batched backfill in issue #2500), so this is a ratchet: it | |
| # compares how many such tables exist at the merge-base vs at HEAD and | |
| # fails only when the total grew. A plain total is what keeps renames, | |
| # copy edits, added locales, and the eventual 'zh'->'zh-CN' migration | |
| # from tripping it — see the script docstring for the three narrower | |
| # keys that were tried and why each broke. `--full` lists the whole | |
| # backlog, `--count` just sizes it. Suppress a single dict with | |
| # a `noqa` comment naming PROMPT_ZH_TW on its opening or closing line | |
| # (comma-separated lists and a bare `noqa` work too, as in the sibling | |
| # gates). Skipped on direct push | |
| # to main — nothing to diff against. | |
| if: github.event_name == 'pull_request' | |
| env: | |
| # Same zizmor template-injection indirection as the i18n-sync step. | |
| BASE_REF: ${{ github.base_ref }} | |
| run: python scripts/check_prompt_zh_tw.py --base "origin/${BASE_REF}" | |
| api-layering: | |
| name: API & layering | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: Set up Python 3.11 | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.11' | |
| - name: Forbid trailing-slash route paths on FastAPI decorators (backend) | |
| # Project convention: every backend HTTP/WebSocket endpoint is | |
| # declared WITHOUT a trailing slash. Avoids Starlette's absolute-URL | |
| # 307 redirect under reverse proxies — root cause of the PR #938 | |
| # chara_manager regression: nginx/etc that don't transparently | |
| # forward Host send the redirect Location to 127.0.0.1:<internal>, | |
| # which the LAN browser can't reach (ERR_CONNECTION_REFUSED). | |
| # The lint exempts the literal '/' root page and explicit alias pairs | |
| # (same function carrying both '/foo' and '/foo/'). See | |
| # .agent/rules/neko-guide.md (§"API URL 末尾不带斜杠") and | |
| # main_routers/characters_router.py docstring. | |
| run: python scripts/check_api_trailing_slash.py | |
| - name: Forbid trailing-slash /api/... URL literals (frontend) | |
| # Counterpart to check_api_trailing_slash.py: the backend may declare | |
| # /api/foo without trailing slash, but if frontend code calls | |
| # fetch('/api/foo/') the same 307 → ERR_CONNECTION_REFUSED happens. | |
| # Regex sniffer over static/, frontend/, templates/. Recognises | |
| # prefix builders ('/api/foo/' + id, `/api/foo/${id}`) and exempts | |
| # them; flags only standalone literals ending in '/'. Suppress with | |
| # // noqa: API_TRAILING_SLASH if calling a third-party API that | |
| # genuinely requires the slash. | |
| run: python scripts/check_frontend_api_trailing_slash.py | |
| - name: Enforce top-level module layering (no inversions, no cycles) | |
| # Top-level packages have a strict ordering — only higher layers may | |
| # depend on lower ones (config/steamworks → utils → memory/main_logic | |
| # → main_routers → plugin → brain → app). The walker descends into | |
| # function bodies and string-form dynamic imports | |
| # (importlib.import_module / __import__), so deferred / conditional | |
| # imports cannot smuggle in a layer inversion or cycle. Run | |
| # `python scripts/check_module_layering.py --show-layers` to print | |
| # the hierarchy. Companion unit test: tests/unit/test_module_layering.py. | |
| run: python scripts/check_module_layering.py | |
| - name: Forbid relative-up markdown links inside docs/ | |
| # docs/ ships through VitePress with itself as the deploy root; | |
| # any markdown link target starting with '..' resolves outside the | |
| # site and breaks deploy. We've shipped this regression more than | |
| # once — this check fails the build before the next attempt lands. | |
| # Fix is to inline the path as code (`foo/bar.js`) instead of a | |
| # link, or move the referenced content into docs/. | |
| run: python scripts/check_docs_no_relative_paths.py | |
| core-contracts: | |
| name: Core package contracts | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: Set up Python 3.11 | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.11' | |
| - name: Enforce main_logic/core structural contracts | |
| # The LLMSessionManager mixin split (#2272) rests on contracts a | |
| # comment cannot enforce: symbols that tests rebind on the | |
| # main_logic.core facade must be read by manager/mixin code through | |
| # the _core_facade late-binding object (a from-import snapshot lets | |
| # isolation-style stubs go silently green while the real function | |
| # runs), mixins hold methods only, method sets stay disjoint, and | |
| # the facade keeps its layout. The patched-symbol set is harvested | |
| # from tests/ by AST on every run, so new patch targets tighten the | |
| # gate automatically. Not diff-based — cheap full check, runs on | |
| # push and PR alike. | |
| run: python scripts/check_core_contracts.py |