* feat(memhub): add model usage audit and cost controls Co-authored-by: Cursor <cursoragent@cursor.com> * fix(memhub): typed source authorization state and managed-key registry ownership Closes #29 Issue #29 surfaced as "WorkBuddy plugin cannot be uninstalled / source sync cannot be turned on", but it is two independent failure domains that were both being reported as a single opaque `desktop_action_failed`: 1. Source sync domain: the UI could not tell whether a source failed to sync because the grant was missing, revoked, or because the source identity or root path changed. Every failure collapsed into one error, so the UI could only say "failed". 2. Host plugin install domain: WorkBuddy shares three registry files (`known_marketplaces.json`, `installed_plugins.json`, `settings.json`) with the host. MemHub recorded a whole-file hash at install time and then refused to uninstall whenever the host (or a reformat) changed any byte anywhere in the file, even in entries MemHub does not own. What changes Domain errors (new `packages/host-bridge/src/domain-error.ts`) - Closed allowlist `DOMAIN_CODES`: grant_required, source_identity_changed, host_uninstall_modified_managed_value, host_uninstall_unknown_owned_file, host_uninstall_needs_review, host_install_journal_invalid, host_install_recovery_required, host_permission_storage_unsafe. - `DomainError` + `domainCodeOf()` so callers stop string-matching on `Error.message`. `diagnosticDomainError()` emits only the code and a repo-relative safe stack, never message text, so diagnostics cannot leak paths or payloads. - Re-exported from `runtime-control` (host-bridge owns the type to avoid a dependency cycle); desktop `observeDesktopAction` now uses `domainCodeOf(error) ?? fallbackCode`. Source authorization state - `SourceAuthorizationState` enum: ok / missing_grant / revoked_grant / source_identity_changed / source_root_unavailable / candidate_changed, plus `SourceGrantStore.authorization_state()` which is read-only and fails closed. - `/api/v1/sources` now returns `authorization_state` per source; contracts and the web app render a concrete next action next to the sync switch instead of a generic failure. - `_change_source_sync` distinguishes sync_prepare_failed, sync_config_write_failed, sync_resume_failed, sync_watcher_failed (409 with the code as detail) while keeping the existing rollback behaviour. - Capture refuses silently but now records a fail-open rejection marker (`sync_disabled` / grant_missing / grant_revoked) that counts occurrences in a time window and carries no payload, path, or session content. WorkBuddy registry ownership (journal v1 -> v2) - Ownership is now the value at MemHub's own key, not the whole file. Added `canonicalJson`, `registryManagedDigest`, `registryWithoutOurs`, `registryHoldsOnlyOurs`. - Install: the plan records `keyPath` + managed digest; apply compares the managed value (CAS) instead of the whole file; the journal writes both the v1 `installed_hash` and the v2 `installed_managed`. - Uninstall: `resolveRegistryOwnership()` compares the v2 digest, falls back to the v1 hash and `isManagedRegistryEntry`, and otherwise produces a review finding instead of throwing. `managedRegistryRemoval()` deletes a file MemHub created when it still holds only our entry; removes just our key when the host has added its own content; and restores recorded bytes only when the rest of the document is unchanged, otherwise patches the current document. - v1 journals migrate: a whole-file hash mismatch no longer blocks uninstall. Tests - `packages/host-bridge/src/domain-error.test.ts` (5): code is carried, message never reaches diagnostics, no guessing on unclassified errors, refuses to parse codes out of free text, allowlist is closed. - `workbuddy-plugin-installer.test.ts` (34 total): uninstall survives a host reformat, removes only our key when the host changed something else, asks for review when a managed entry was changed, migrates a v1 journal, keeps a registry the host now uses, installs over a post-plan change. - `apps/core/tests/test_source_authorization_state.py` (12): every state, fail-closed ordering, capture rejection names, marker contains no secret or path, recording never raises. - `apps/web`: authorization note rendered for revoked grant and hidden when ok. Verification - host-bridge WorkBuddy suite 34/34; domain-error 5/5. - apps/web 1906 tests across 105 files pass. - pnpm -r typecheck clean; biome check clean on touched files. - core-fast: 232 failed / 4131 passed, identical failure count to the clean baseline (232 failed / 4119 passed) with +12 from the new tests; the failures are pre-existing and unrelated (confirmed by stashing and re-running). * feat(memhub): add a start/stop switch for model sedimentation Shipped builds gave the user no way to stop model calls: the model content permission switch is gone (removed with its endpoint in #20) and there is no pause or remove entry either, so a configured model keeps turning conversations into memories and keeps costing money. Adds a user switch in settings: stopping keeps the model configuration and the already imported conversations, leaves queued work pending instead of failing it, and stops new work, enqueueing and backfill so nothing further reaches the model. Starting again resumes the same work without re-entering a key. The flag lives in Core's own user config (config.json), so no schema migration is needed, and the supervisor takes it as an optional callback so every existing caller and test keeps its current behaviour. * test(desktop): keep host-bridge mocks whole and assert the review contract The memhub CI job failed four desktop tests after the issue #29 change. Both were test-side contracts that the new ownership model intentionally moves, not regressions in the fix itself. 1. `host-control-ownership.test.ts` mocked `@one-memory/host-bridge` with a whitelist that carried only `RecallBroker`. Main now imports `domainCodeOf`, which runtime-control re-exports from host-bridge, so the mock handed back undefined and every error path that called it threw a TypeError instead of the error the test was asserting on. The mock now spreads the real module and overrides only `RecallBroker`, which is the part it was written for. 2. `host-permissions.test.ts` asserted the pre-#29 contract, where a change anywhere in a shared registry made uninstall throw `host_uninstall_modified_files`. Ownership is now the value at our own key, so a change to that value is reported instead of guessed at: the plan schedules no removal, names the file in a review finding, and applying it refuses with `host_uninstall_needs_review`. The test keeps the guarantee it existed for, that those bytes are never overwritten, and now also asserts that apply refuses, so the safety is checked at both steps. Verification: full desktop suite 38 files / 436 tests pass, `pnpm -r typecheck` clean, biome reports only two pre-existing warnings on lines this change does not touch. * fix(logging): keep info diagnostics actionable Co-authored-by: Cursor <cursoragent@cursor.com> * test(memhub): register the sedimentation switch test in the core test inventory New core test files must appear in TEST_INDEX.md and in the classification inventory's expected count and digest, and the backfill case needs a settled turn because an unanswered user tail is not backfill material. * feat(diagnostics): expose build info and fullscreen logs Co-authored-by: Cursor <cursoragent@cursor.com> * fix(settings): align help controls and tighten spacing Co-authored-by: Cursor <cursoragent@cursor.com> * release: prepare MemHub 1.0.4 build 8 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(memhub): clear pre-existing dev lint errors and flaky SDK dedup tests Three things blocking CI that came in with dev and are unrelated to the sedimentation switch itself: - biome errors in DebugLogPanel.tsx (import order) and ModelUsageView.tsx (refreshToken is an intentional refresh trigger, annotated like the existing modelVersion trigger in R6Settings). - test_extractor_prompt_override.py's fake LLM now accepts keyword args the real pipeline passes since the usage audit landed (audit_stage). - the fake embeddings in the intention dedup tests used the process-random hash(), so two different texts collided roughly one run in 32 and were misread as duplicates; switched to a deterministic md5 index. * fix(cli): count recall characters as code points A recall whose hits contain an emoji outside the BMP was reported as response_invalid even though the data and the search were fine: the CLI cross-checked the response by summing String.prototype.length, which counts UTF-16 code units, against Core's used_characters, which Python's len() counts in code points. One emoji is two units here and one character there, so any hit set with an emoji failed the check. The sibling validator in host-bridge (context-protocol.ts) already counted code points, so the CLI was the odd one out rather than a deliberate choice. - validateContextResponse now sums through characterCount(), which counts code points, matching Core. - Two regression tests: an emoji hit whose used_characters follows code points must stay grounded, and the same response counted as UTF-16 units must still be refused, so the cross-check is not simply loosened. - The contract now states used_characters counts Unicode code points and is not an encoding length, so a client in another language does not repeat the comparison. Verified by reverting the source change: the first test fails on the old implementation and passes with it. Closes #30 * fix(web): keep the model recovery entry reachable while a probe is pending Closes #40 A pending model probe fenced the configuration editor, and the only recovery entry ("核对测试结果") lived inside the area the fence kept shut. Reaching it required opening the form; opening the form required having recovered. People with a stuck probe could not change or repair their model at all. The fence itself was doing its job and still is. What was wrong is that refusal also hid the way out. R6Settings - openConfiguration() now opens the model area whether or not the editor accepts, so the recovery entry inside it is reachable. editProfile() and add() still return false while the fence is up; nothing about the protection changed. - The refusal notice is reworded to match: the configuration cannot be changed yet, and the next step is down in the area that just opened. Saying "cannot be opened" was both untrue now and unhelpful before. ModelSettings - A refused open is remembered instead of dropped. Once the fence clears, the intent is replayed, so the person lands in the form they asked for. Without this they would get a blank new-configuration form the moment the probe reconciled, which invites a duplicate entry. - Replaying only fills fields in. It never sends a request, so no test is re-sent implicitly; "核对测试结果" remains the only thing that does. Tests - R6Settings: with a current model and without one, the recovery entry is reachable while the editor is fenced, and the refusal is still reported. - ModelSettings: after reconciling, the form reopens on the profile that was asked for, and the probe is sent exactly once. - Both new R6Settings cases and the ModelSettings case fail on the previous code. * perf(runtime-control): decide runtime reuse before staging the payload Every App launch copied the whole 373MB bundle into a temp directory and hashed it twice before asking whether the selected runtime could be reused. On a machine that already runs this build the answer is yes and all of that work is thrown away: measured on a release Mac bundle, 6.9s to copy and about 3.8s of hashing (fileDigests, then verifyInstalledDistribution over the same tree). installPackagedRuntime now describes the payload in place first. The staged tree is a byte for byte copy of resources/{core,host-bridge,runtime,web} plus two generated launchers, so the same relative paths and digests can be read without copying anything. reuseSelectedSameBuild takes that description instead of a staged directory, and only a payload that is going to be installed is ever staged. The decision itself is unchanged: it still compares every file digest except bin/node, still repairs the packaged launcher when the App moved, and still raises installed_version_conflict or distribution_hash_mismatch exactly as before. Nothing was replaced by a cheaper fingerprint, because a wrong reuse is what those errors exist to prevent. Hashing also runs through a small worker pool now; the digests are identical and the manifest keeps its sorted key order. New test: describeBundlePayload must equal the manifest the staged copy produces, so moving the decision ahead of the copy cannot decide something different. Verified read-only against the installed release bundle: 1534 files, identical file sets, zero digest mismatches. * perf(desktop): raise a startup window before the runtime and Core come up Opening the App currently shows nothing at all until initialize finishes. On this machine that is 55.6s from desktop.initializing to desktop.ready (about 19s installing and starting the managed runtime, then about 36s for Core to become ready), and window.requested is logged after desktop.ready. A launch that slow with no window reads as a launch that failed, and people click the icon again. startDesktopLifecycle now takes two optional hooks. showStartupWindow runs before initialize and loads a placeholder; revealStartupWindow runs after initialize and swaps that same window onto the real app. Because the window is reused rather than replaced, there is no second window and no flash. The placeholder is served from the app protocol as /__startup, in memory, not as a file in the web bundle. It performs no IPC: it renders before initialize has registered a single handler, so anything it asked for would fail and leave the page in an error state for the whole wait. That route is also reserved, so a file of the same name inside the bundle can never be reached through it. Raising the placeholder is best effort. It only fills a wait that would otherwise be blank; failing to draw it must not stop the services or the real window. Tests: five lifecycle cases cover the ordering (placeholder before initialize, reveal after, reuse of the same window, nothing for a background launch, and a real window when the placeholder was closed or could not be raised). The four suites that mock ./app-protocol now spread the real module instead of whitelisting it, which is what broke when the protocol gained exports. host-control-ownership gains the BrowserWindow methods the placeholder path uses, and its "no window" assertion now names the placeholder, since a placeholder window is expected there by design. * fix(core): stop the sedimentation switch failing its own request Turning the switch off answered "停止未能确认,模型可能仍在整理记忆,请重试" every time. The value was saved and the error was a lie the endpoint told afterwards: `put_sedimentation` logged `memory_sedimentation.toggled` with `details={"enabled": ...}`, `enabled` is not in the diagnostic allow-list, and `_details` raises. That ValueError escaped the endpoint, so the request is recorded in this machine's log three times as `api.request.failed` / `core.request.unhandled` with `diagnostic_detail_key_invalid`, and the UI took the 500 as an unconfirmed stop. Because the raise sits between the save and the resume, starting again was broken the same way: `resume_allowed()` never ran, so work parked by a stop stayed parked. Three changes, in the order they matter: 1. `enabled` is now a legal detail key. It is a bounded boolean, the same shape as `spawned` and `index_complete`. 2. `diagnostic_event` no longer lets a rejected payload reach the caller. It already swallowed `OSError` with the comment "Diagnostics must not become a new product outage"; an unlisted key is the same failure seen from the caller, and it is exactly what turned every external recall into a 500 and then killed the process last time. The payload is still wrong and still caught in CI — now by every module rather than five. 3. `test_diagnostic_payload_keys.py` walks every module instead of a hand-picked list. The two payloads that caused real outages were in files nobody thought to add to that list; the only shape of the check that holds the line is "every module, always". This file was already failing on dev with `api.py:3673: unlisted details key 'enabled'` — the bug was known to the suite and shipped anyway. `DiagnosticLog.write` still raises, so the rejection itself is unchanged and still tested; only the callers are protected. * fix(core): let the drain claim a manual note's job A memory added by hand stayed on "原话已保存,等待整理" indefinitely, with `task_id: null`: the note's durable job was never claimed, so no task was ever created for it. On this machine the job is still `pending` with `attempts=0` more than eight hours after it was saved, while the write gate admits its source without complaint. The drain had two different answers to one question. `source_writes_permitted` is `_source_writes_on`, which admits `one-memory-manual` by its grant. The claim gate was `_config_store(app).sync_enabled`, the per-app toggle, and `one-memory-manual` is not a connected app: it never appears in the configuration document, so that predicate answers False for it. The job survived the `enabled_sources` prefilter and was then dropped row by row, on every tick, forever. Nothing ever failed and nothing ever ran. The claim gate now asks the same question the write gate asks. Admission is one decision; giving it two predicates is what parked the work. Two tests pin it. One builds the drain as the composition root does and shows the job is claimed, and shows the shipped wiring stranding the same job, so the regression is visible if the toggle comes back. The other records the asymmetry itself: `_source_writes_on` is True for the manual source while `sync_enabled` is False, which is why the pair could disagree at all. No migration and no reinstall: existing pending jobs are picked up on the next drain tick. * fix(memhub): make sedimentation pause stop model dispatch Co-authored-by: Cursor <cursoragent@cursor.com> * feat(memhub): schedule memory writes by event time Co-authored-by: Cursor <cursoragent@cursor.com> * fix(core): answer the sedimentation switch before the backlog walk Turning "用模型沉淀记忆" back on wrote the choice to the store and then awaited `resume_allowed()` inside the PUT handler. That call is not a nudge: it runs maintenance over every unfinished task and then a whole-queue scan, and the bounded scan waited -- holding the scan lock -- for the queue to drop under its cohort ceiling. With a real backlog the queue never drops, so the request never returned: one toggle in the field is still open 20+ minutes later with no `api.request.completed`, while the renderer gave up at its 30s timeout and showed "启用未能确认,仍显示上次确认的状态;请重试。" for a switch that had in fact been saved. The choice is the durable fact; the walk is follow-up work: - The handler now stores the choice and returns it, and starts the resume as a watched background task (same idiom as the other Core background walks). Repeated toggles reuse the running walk instead of stacking. - A bounded scan now stops at its ceiling instead of sleeping under the scan lock waiting for capacity, which also un-starves the periodic scan that was queued behind it. It reports `queue_saturated` rather than `no_tasks`, so the status stops claiming there is nothing to do while hundreds wait. Verified on the field log first: the failing toggle is the only request in the window with `api.request.started` and no `api.request.completed`, and its trace shows `memory_sedimentation.toggled` succeeded 6ms in at `pending_count` ~500. New tests fail on the old code (timeout) and pass now. * fix(core): stop a blank author from erasing a whole conversation A user turn whose host did not say who wrote it carries `written_by=unknown`. The write binding's allow-list had every other role/author pair but not that one, so verifying the context raised `hy_memory_write_verification_context_scope` and the task ended as `model_content_outcome_unknown` after two attempts: a memory the model may well have written, that can never be confirmed, and a conversation that is never sedimented again. This is not a rare shape. On this machine 1078 user records across 1002 WorkBuddy conversations are `unknown`, and the failures were 42 per 15 minutes in the field log — every one of them a task reporting `result_count=0` while 402 sat pending. The author is missing, not the turn: the record is still inside the same conversation, workspace and source, which is what the binding actually checks, and the turn assembly already sent it. Accepting it here puts the verification back in agreement with what was sent, the same reasoning that added ("user", AGENT) for sub agent questions. New test fails on the old code at hy_source_context.py:76 — the same frame as the field stack — and passes now. * fix(core): stop an abandoned read reporting a failure nobody owns `in_read_pool` shields the wrapped worker, so a lane that runs out of budget gives up on the answer without yanking a query out from under SQLite. The caller cancels; the read runs on. When such an abandoned read then failed, nothing awaited its future any more: `shield` drops its inner callback as soon as the outer future is cancelled, so the inner future finished with an exception that nobody retrieved. asyncio reports that at GC, and the loop handler files it as `core.loop.task_exception` -- a red error sitting next to a lane that had already degraded on its own budget and already said so. On this machine that is the `raw_context_timeout` pair in the field log: a Future, not a Task, carrying TimeoutError('raw_context_timeout'), reported three times in half an hour. The lane result itself was right; only the diagnostics were arguing with themselves. Retrieving the outcome on completion keeps the diagnostics honest without hiding anything: awaiting the read still raises for a caller who waited, which the second case pins down. The new case fails on the previous code (it reports the TimeoutError) and passes now. * fix(core): one task with no inputs no longer strands every unknown task `MemoryTaskRunner._request` falls back to `chunk[0]` when no record is a person-written user turn. A task whose inputs are gone yields an empty chunk, so that fallback raised IndexError -- an error no caller can read. A reconcile pass catches domain errors per task and moves on, so IndexError did not skip the task: it ended the pass, and every unknown task behind it stayed unknown while the retry backed off 1, 2, 4 ... 128 and gave up. On this machine 68 of 303 unknown tasks had no inputs, which is exactly the `reconcile_unknown / error_type=IndexError` backing off in the field log. Those 68 cannot be reconciled either way -- there is nothing to ask HY about -- but the 235 that can must not be held hostage to them, and this is what stands between a task whose model write may already have landed and its confirmation. An empty chunk is now a domain error, so the pass skips the task and continues. Also folds in one formatting hunk left over in this file from the previous change, so the formatter is clean again. The new case fails on the previous code at memory_processing.py:1036 with the same IndexError the log shows, and passes now. * chore(memhub): bump release build number to 10 The installer refuses a package whose bytes differ while its build number stays the same, so the four Core fixes on dev need their own number to be installable over the 1.0.4 / build 9 package already on the machine. * fix(core): retire a window whose remaining rows FIFO refuses The event-time window asked only whether its ten-minute range still held *some* pending row before deciding the anchor was still active. A pending row that FIFO holds back is not claimable: it may be blocked by an earlier sibling of the same Harness whose own event time sits outside the range, and that sibling can never be claimed from inside this window. The anchor then stayed pinned forever and the whole queue starved. Live shape on one machine: the anchor sat on 2026-10-07T03:24Z while the four pending rows inside it were blocked by 929/930/1243/2347 earlier workbuddy jobs, the earliest of them dated 2026-03-12. Completions stopped at 09:59 and 3.8k queued turns sat at zero output; restarting does not help because the anchor is persisted. The contract (docs/contracts/memory-event-time-scheduling-v1.md) retires a window once every *eligible* pending/running row in the range has settled or parked, and re-anchors on the next oldest eligible job. Reuse the claim's own predicate — free lease, no busy Harness, no earlier unsent sibling — so "the window is busy" and "the window can still be worked" finally agree. Anchor selection and in-window selection already used it; only the liveness check did not. * fix(core): a refused source context is a failure, not an unknown write Issue #44: an OpenCode import carried `role=user, written_by=unknown`. The role/author set now admits it, but the row still never moved: `HyWriteBindings` is built inside `ingest`, and the invocation marker is recorded *before* that call, so the refusal arrived with the marker already set. The classifier checked `hy_invoked` first and parked the task as `unknown` — a state that means "HY may hold a write we cannot confirm" about a request that provably never reached HY. The reason stayed off the row, the task could not be re-driven, and the queue behind it stopped. `hy_memory_write_verification_context_scope` is raised from that construction alone, and `_check_source_context` (which does run after the call) raises `hy_memory_evidence_changed` instead, so the code is unambiguous: nothing was sent. It joins `PRE_SEND_REFUSAL_CODES`, and both classifiers now test that set before the marker: the durable path settles `failed` with the marker cleared so reclaim does not treat a row HY never saw as possibly-sent, and the task path fails with the code instead of marking unknown. Regression covers both directions: a pre-call refusal must end `failed` with `hy_invoked=0`, and a post-call refusal (`authority_invalid`) must still be `unknown` — the fix narrows the unknown set, it does not widen the failure set. * chore(memhub): bump release build number to 11 Carries the window starvation fix (dc0ba49) and the pre-send refusal fix (0df84c0). A new build number keeps the package distinguishable from build 10 and lets it install over the current one. * refactor(memhub): simplify durable memory scheduling Co-authored-by: Cursor <cursoragent@cursor.com> * chore(memhub): bump release build number to 12 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(security): parse diagnostic stack paths linearly Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): sync sedimentation switch status to home (#51) --------- Co-authored-by: alvinfei <alvinfei@tencent.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: kristinhe <kristinhe@tencent.com>
737 lines
28 KiB
Python
737 lines
28 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
Coding Memory - Scene Judge
|
||
|
||
两个职责(同模块、共用 LLMConfig.model):
|
||
|
||
1. classify_messages_scene(messages, llm)
|
||
写入端:基于 user query + tool 名称的简化视图,对整段 messages 做
|
||
chat / working / coding 三分类。不切 segment、不分 turn。
|
||
|
||
2. classify_queries_scene(queries, llm)
|
||
搜索端:以 queries 中末尾为目标 query,前置 queries 作为上下文,判定具体 scene。
|
||
|
||
旧的 classify_*_is_coding API 保留兼容:working/coding 都返回 True。
|
||
|
||
对外 scene 与自动路由语义见 docs/scene_coding_usage.md。
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
import time
|
||
from typing import List, Optional, Dict, Any
|
||
|
||
from ..agent.llm_provider import LLMProvider
|
||
from ..pipelines.base import ChatMessage
|
||
from .preproc import extract_tool_summary
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
_JUDGE_MAX_TURNS = 20
|
||
_JUDGE_HEAD_TURNS = 5
|
||
_JUDGE_USER_CONTENT_LIMIT = 1_000
|
||
_JUDGE_USER_CONTENT_HEAD = 200
|
||
_JUDGE_USER_CONTENT_TAIL = 200
|
||
_JUDGE_MAX_QUERY_CHARS = 300
|
||
_JUDGE_MAX_QUERIES = 20
|
||
|
||
|
||
async def _store_coding_log(
|
||
cache: Any,
|
||
*,
|
||
request_id: str,
|
||
user_id: str,
|
||
agent_id: str,
|
||
session_id: str,
|
||
step: str,
|
||
prompt: str,
|
||
response: str,
|
||
parsed: str = "",
|
||
elapsed_ms: float = 0.0,
|
||
prompt_tokens: int = 0,
|
||
completion_tokens: int = 0,
|
||
total_tokens: int = 0,
|
||
) -> None:
|
||
"""coding 链路统一打点 helper:best-effort,失败只 warning,绝不影响主流程。
|
||
|
||
cache 为 None(未注入)时直接跳过,coding 模块可独立使用/单测。
|
||
"""
|
||
if cache is None:
|
||
return
|
||
try:
|
||
await cache.store_pipeline_log(
|
||
request_id=request_id or "",
|
||
user_id=user_id or "",
|
||
agent_id=agent_id or "default_agent",
|
||
session_id=session_id or "",
|
||
step=step,
|
||
prompt=prompt or "",
|
||
response=response or "",
|
||
parsed=parsed or "",
|
||
elapsed_ms=elapsed_ms,
|
||
prompt_tokens=prompt_tokens,
|
||
completion_tokens=completion_tokens,
|
||
total_tokens=total_tokens,
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"[coding-log] store {step} failed: {e}")
|
||
|
||
|
||
def _resolve_llm_temperature(
|
||
llm_provider: LLMProvider, default: Optional[float] = None,
|
||
) -> Optional[float]:
|
||
"""解析温度;返回 None 表示**不传该参数**,由 provider 统一决定。
|
||
|
||
此前这里 fallback 到 0.1,会绕过用户配置(kimi 等平台对 temperature 有硬约束,
|
||
Opus 5 更是只接受 1 或不传)。现在配置要求省略时返回 None,一路传到 provider
|
||
就不会出现在请求里。
|
||
"""
|
||
config = getattr(llm_provider, "config", None)
|
||
if config is not None:
|
||
llm = getattr(config, "llm", None)
|
||
if llm is not None:
|
||
if getattr(llm, "omit_temperature", False):
|
||
return None
|
||
temp = getattr(llm, "temperature", None)
|
||
if temp is not None:
|
||
return float(temp)
|
||
inner = getattr(llm_provider, "_llm_config", None)
|
||
temp = getattr(inner, "temperature", None) if inner is not None else None
|
||
if temp is not None:
|
||
return float(temp)
|
||
return default
|
||
|
||
|
||
# ================================================================
|
||
# 写入端:整段三分类
|
||
# ================================================================
|
||
|
||
CLASSIFY_MESSAGES_PROMPT = """\
|
||
You are a single-label scene classifier. Decide the DOMINANT scene of the entire
|
||
conversation chunk passed to you.
|
||
|
||
Output exactly one of:
|
||
"coding" ← source code or another developer artifact is the work object or the
|
||
implementation medium. Examples: writing/reading/editing/debugging/
|
||
refactoring code; scripts, SQL, notebooks or automation code; tests,
|
||
builds, compiler/runtime errors, APIs, SDKs, dependencies, code review,
|
||
deployment code/config tightly coupled to software development. If
|
||
code is explicitly used to build the solution, choose coding even if
|
||
the business goal itself sounds like an office/working task.
|
||
"working" ← a concrete productivity task is being carried out, but code is not
|
||
the central work object/medium. Examples: documents, reports, PPT/
|
||
slides, spreadsheets, office workflows, writing/editing/translation,
|
||
finance/accounting/investment/business analysis, research and web
|
||
research for a deliverable, email/calendar/project management, data
|
||
entry and non-code data analysis, image/design/content production,
|
||
file/folder organization, software installation/configuration,
|
||
device/disk/backup/system operations, and other computer operations.
|
||
"chat" ← casual conversation, personal information/preferences, advice or
|
||
general factual Q&A with no productivity deliverable or operation.
|
||
Search/browse tools do NOT make a task working: checking weather,
|
||
news, celebrities, restaurants or similar facts for conversation is
|
||
still chat unless the results are used to produce a concrete work
|
||
artifact or execute a productivity task.
|
||
|
||
Rules:
|
||
- Look at the WHOLE chunk, not individual turns. A few stray off-topic turns
|
||
inside a clearly task-focused chunk should not change its scene.
|
||
- Tool names are evidence, never the answer by themselves. Infer what the user is
|
||
accomplishing and what artifact is being produced.
|
||
- coding beats working when real code is authored, edited, debugged, tested or
|
||
used as the implementation medium. Merely using a browser/search tool is not coding.
|
||
- working beats chat only when there is a concrete productivity action or deliverable.
|
||
- If evidence is insufficient, choose chat rather than inventing a work scene.
|
||
|
||
Conversation summary (turns with user query + tool names used):
|
||
{turns_block}
|
||
|
||
Output strict JSON only, no markdown. Keep "reason" very short (max 15 words):
|
||
{{"scene": "chat|working|coding", "reason": "..."}}
|
||
"""
|
||
|
||
|
||
async def classify_messages_scene(
|
||
messages: List[ChatMessage],
|
||
llm_provider: LLMProvider,
|
||
*,
|
||
max_tokens: int = 400,
|
||
temperature: Optional[float] = None,
|
||
cache: Any = None,
|
||
request_id: str = "",
|
||
user_id: str = "",
|
||
agent_id: str = "default_agent",
|
||
session_id: str = "",
|
||
) -> str:
|
||
"""
|
||
对整段 messages 做 chat / working / coding 单一场景判定。
|
||
|
||
复用 LLMConfig.model(不引入独立 classifier 配置)。
|
||
temperature=None 时使用 config.llm.temperature(与 MemAgent 路径一致),
|
||
避免硬编码 0.0 触发 kimi/deepseek 等模型的温度硬约束。
|
||
fail-safe:LLM 调用失败或解析失败,返回 "chat"。
|
||
|
||
cache 非空时落 CODING_JUDGE pipeline log(无论判 coding 还是 chat 都记录一条)。
|
||
"""
|
||
if not messages:
|
||
return "chat"
|
||
|
||
summary = extract_tool_summary(messages)
|
||
turns_block = _format_turns(summary)
|
||
prompt = CLASSIFY_MESSAGES_PROMPT.format(turns_block=turns_block)
|
||
|
||
if temperature is None:
|
||
temperature = _resolve_llm_temperature(llm_provider)
|
||
|
||
_t0 = time.perf_counter()
|
||
try:
|
||
resp = await llm_provider.complete(
|
||
prompt=prompt,
|
||
max_tokens=max_tokens,
|
||
temperature=temperature,
|
||
audit_stage="coding_judge",
|
||
)
|
||
_elapsed = round((time.perf_counter() - _t0) * 1000, 2)
|
||
result = _parse_scene_json(resp.content)
|
||
if result is None:
|
||
logger.warning(
|
||
f"[coding-judge] failed to parse classify_messages output: {resp.content!r}; defaulting to chat"
|
||
)
|
||
await _store_coding_log(
|
||
cache, request_id=request_id, user_id=user_id, agent_id=agent_id,
|
||
session_id=session_id, step="CODING_JUDGE", prompt=prompt,
|
||
response=resp.content or "", parsed=json.dumps({"scene": "chat", "is_coding": False, "parse_error": True}, ensure_ascii=False),
|
||
elapsed_ms=_elapsed, prompt_tokens=resp.prompt_tokens,
|
||
completion_tokens=resp.completion_tokens, total_tokens=resp.tokens_used,
|
||
)
|
||
return "chat"
|
||
scene = result["scene"]
|
||
parsed = {**result, "is_coding": scene != "chat"}
|
||
logger.info(
|
||
f"[coding-judge] scene={scene} reason={result.get('reason', '')!r}"
|
||
)
|
||
await _store_coding_log(
|
||
cache, request_id=request_id, user_id=user_id, agent_id=agent_id,
|
||
session_id=session_id, step="CODING_JUDGE", prompt=prompt,
|
||
response=resp.content or "", parsed=json.dumps(parsed, ensure_ascii=False),
|
||
elapsed_ms=_elapsed, prompt_tokens=resp.prompt_tokens,
|
||
completion_tokens=resp.completion_tokens, total_tokens=resp.tokens_used,
|
||
)
|
||
return scene
|
||
except Exception as e:
|
||
logger.warning(f"[coding-judge] classify_messages LLM failed: {e}; defaulting to chat")
|
||
await _store_coding_log(
|
||
cache, request_id=request_id, user_id=user_id, agent_id=agent_id,
|
||
session_id=session_id, step="CODING_JUDGE", prompt=prompt,
|
||
response=f"[LLM error] {type(e).__name__}: {str(e)[:300]}",
|
||
parsed=json.dumps({"scene": "chat", "is_coding": False, "llm_error": True}, ensure_ascii=False),
|
||
elapsed_ms=round((time.perf_counter() - _t0) * 1000, 2),
|
||
)
|
||
return "chat"
|
||
|
||
|
||
async def classify_messages_is_coding(
|
||
messages: List[ChatMessage],
|
||
llm_provider: LLMProvider,
|
||
**kwargs: Any,
|
||
) -> bool:
|
||
"""兼容旧 API:working/coding 都表示应走生产力记忆链路。"""
|
||
return (await classify_messages_scene(messages, llm_provider, **kwargs)) != "chat"
|
||
|
||
|
||
# ================================================================
|
||
# 搜索端:用 queries 上下文判类
|
||
# ================================================================
|
||
|
||
CLASSIFY_QUERIES_PROMPT = """\
|
||
Classify the LATEST query as "chat", "working", or "coding".
|
||
Use the earlier queries (if any) as context for disambiguation.
|
||
|
||
Use "coding" when source code, scripts, SQL/notebooks, tests/builds, APIs/SDKs,
|
||
dependencies, debugging or code review are the work object or implementation
|
||
medium. If code is explicitly used to produce the result, choose coding.
|
||
|
||
Use "working" for concrete non-code productivity work: documents/reports/PPT,
|
||
spreadsheets, writing/translation, finance or business analysis, research for a
|
||
deliverable, email/calendar/project management, content/design, file organization,
|
||
software/device configuration, disk/backup/system operations, and similar work.
|
||
|
||
Use "chat" for casual conversation, personal information/preferences, advice or
|
||
general factual Q&A with no productivity deliverable. Search/browse tools alone do
|
||
not make work: weather/news/celebrity/restaurant lookup for conversation is chat.
|
||
Tool names are evidence, never the classification by themselves.
|
||
|
||
Recent queries (oldest → newest, last is target):
|
||
{queries_block}
|
||
|
||
Output strict JSON only, no markdown:
|
||
{{"scene": "chat|working|coding"}}
|
||
"""
|
||
|
||
|
||
async def classify_queries_scene(
|
||
queries: List[str],
|
||
llm_provider: LLMProvider,
|
||
*,
|
||
max_tokens: int = 100,
|
||
temperature: Optional[float] = None,
|
||
) -> str:
|
||
"""对 queries 判定 chat / working / coding;失败时回退 chat。"""
|
||
if not queries:
|
||
return "chat"
|
||
|
||
bounded_queries = _bounded_queries(queries)
|
||
queries_block = "\n".join(
|
||
f"{i + 1}. {repr(q)}" + (" ← target" if i == len(bounded_queries) - 1 else "")
|
||
for i, q in enumerate(bounded_queries)
|
||
)
|
||
prompt = CLASSIFY_QUERIES_PROMPT.format(queries_block=queries_block)
|
||
if temperature is None:
|
||
temperature = _resolve_llm_temperature(llm_provider)
|
||
try:
|
||
resp = await llm_provider.complete(
|
||
prompt=prompt, max_tokens=max_tokens, temperature=temperature,
|
||
)
|
||
result = _parse_scene_json(resp.content)
|
||
if result is None:
|
||
logger.warning(
|
||
f"[coding-judge] failed to parse classify_queries output: {resp.content!r}; defaulting to chat"
|
||
)
|
||
return "chat"
|
||
scene = result["scene"]
|
||
logger.info(f"[coding-judge] queries scene={scene}")
|
||
return scene
|
||
except Exception as e:
|
||
logger.warning(f"[coding-judge] classify_queries LLM failed: {e}; defaulting to chat")
|
||
return "chat"
|
||
|
||
|
||
async def classify_queries_is_coding(
|
||
queries: List[str],
|
||
llm_provider: LLMProvider,
|
||
*,
|
||
max_tokens: int = 100,
|
||
temperature: Optional[float] = None,
|
||
) -> bool:
|
||
"""
|
||
对一组 queries(末尾为目标)判类。
|
||
|
||
单 query 输入也支持(前置上下文为空)。
|
||
temperature=None 时使用 config.llm.temperature。
|
||
fail-safe:LLM 调用失败或解析失败,返回 False(走现有 chat 召回)。
|
||
"""
|
||
return (await classify_queries_scene(
|
||
queries, llm_provider, max_tokens=max_tokens, temperature=temperature,
|
||
)) != "chat"
|
||
|
||
|
||
# ================================================================
|
||
# 搜索端:极简判类 + 改写(单次 LLM,搜索路径必须低时延)
|
||
# ================================================================
|
||
|
||
CLASSIFY_AND_REWRITE_PROMPT = """\
|
||
Task: Given queries (oldest -> newest, last is target), do TWO things:
|
||
1) Classify the target as chat, working, or coding using the same boundary:
|
||
coding = code/scripts/SQL/notebooks/tests/builds/APIs/debugging are the work;
|
||
working = concrete non-code productivity deliverable or computer operation;
|
||
chat = casual/personal/general Q&A, including incidental web-search lookups.
|
||
Tool use alone never decides the scene.
|
||
2) Rewrite the target into ONE concise standalone query, expanding pronouns /
|
||
restoring omitted context using the earlier queries. Keep the same language.
|
||
|
||
Output STRICTLY in this format, nothing else:
|
||
<chat, working, or coding>
|
||
<rewritten query>
|
||
|
||
Queries:
|
||
{queries_block}
|
||
"""
|
||
|
||
|
||
async def classify_and_rewrite_queries(
|
||
queries: List[str],
|
||
llm_provider: LLMProvider,
|
||
*,
|
||
max_tokens: int = 200,
|
||
temperature: Optional[float] = None,
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
单次 LLM 调用同时做:
|
||
1) 判定 target query (queries[-1]) 是 chat / working / coding
|
||
2) 用前置 queries 上下文做改写(消除指代 / 补全省略),返回 rewrite_query
|
||
|
||
输出格式刻意保持极简(避免 JSON 解析开销 + 让 LLM 输出短):
|
||
第一行: chat / working / coding(兼容旧 0/1)
|
||
第二行: rewritten query
|
||
|
||
Returns:
|
||
{"scene": str, "is_coding": bool, "rewrite_query": str, "ok": bool}
|
||
失败时 scene=chat、ok=False,rewrite_query 等于 queries[-1]
|
||
|
||
fail-safe:LLM 失败 / 解析失败一律 ok=False(caller 应回退默认 chat 路径)。
|
||
"""
|
||
target = queries[-1] if queries else ""
|
||
fallback = {"scene": "chat", "is_coding": False, "rewrite_query": target, "ok": False}
|
||
if not queries:
|
||
return fallback
|
||
|
||
bounded_queries = _bounded_queries(queries)
|
||
queries_block = "\n".join(
|
||
f"- {q}" + (" <-- target" if i == len(bounded_queries) - 1 else "")
|
||
for i, q in enumerate(bounded_queries)
|
||
)
|
||
prompt = CLASSIFY_AND_REWRITE_PROMPT.format(queries_block=queries_block)
|
||
|
||
if temperature is None:
|
||
temperature = _resolve_llm_temperature(llm_provider)
|
||
|
||
try:
|
||
resp = await llm_provider.complete(
|
||
prompt=prompt,
|
||
max_tokens=max_tokens,
|
||
temperature=temperature,
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"[coding-judge] classify_and_rewrite LLM failed: {e}; falling back to chat")
|
||
return fallback
|
||
|
||
text = (resp.content or "").strip()
|
||
if not text:
|
||
logger.warning("[coding-judge] classify_and_rewrite empty content; falling back to chat")
|
||
return fallback
|
||
|
||
# 去 markdown fence(保险)
|
||
if text.startswith("```"):
|
||
lines = text.split("\n")
|
||
body = []
|
||
in_fence = False
|
||
for line in lines:
|
||
if line.startswith("```"):
|
||
in_fence = not in_fence
|
||
continue
|
||
body.append(line)
|
||
text = "\n".join(body).strip() or text
|
||
|
||
# 拆解:第一行 = scene(兼容旧 0/1),剩下的 = rewrite
|
||
lines = text.split("\n", 1)
|
||
head = lines[0].strip().rstrip(",.").rstrip("。,")
|
||
rewrite = (lines[1].strip() if len(lines) > 1 else "").strip()
|
||
if not rewrite:
|
||
rewrite = target # 没改写就用原 target
|
||
|
||
scene = _parse_scene_label(head)
|
||
if scene is None:
|
||
logger.warning(
|
||
f"[coding-judge] classify_and_rewrite unrecognized head={head!r}; falling back to chat"
|
||
)
|
||
return {"scene": "chat", "is_coding": False, "rewrite_query": rewrite, "ok": False}
|
||
is_coding = scene != "chat"
|
||
|
||
logger.info(
|
||
f"[coding-judge] classify_and_rewrite scene={scene} "
|
||
f"rewrite={rewrite!r}"
|
||
)
|
||
return {"scene": scene, "is_coding": is_coding, "rewrite_query": rewrite, "ok": True}
|
||
|
||
|
||
# ================================================================
|
||
# 搜索端:合并分析(判类 + 改写 + 时间跨度),单次 LLM
|
||
#
|
||
# 支持三种组合(由 caller 通过 want_classify / want_rewrite 控制):
|
||
# - want_classify + want_rewrite : working memory + query rewrite 同开,一次 LLM
|
||
# - want_rewrite only : 只 query rewrite(恒 chat / 不判类)
|
||
# - want_classify only 走旧的 classify_and_rewrite_queries / classify_queries_is_coding
|
||
#
|
||
# 时间:LLM 只输出相对锚点(today / N days ago / an ISO date),由本函数结合
|
||
# current_time 换算成 created_after(unix 秒,下界)。无上界。
|
||
# ================================================================
|
||
|
||
ANALYZE_QUERIES_PROMPT = """\
|
||
You rewrite a user's memory-search query. Given queries (oldest -> newest, last is the target){classify_clause}.
|
||
|
||
Current time: {current_time}
|
||
|
||
Do:
|
||
{task_lines}
|
||
R) Rewrite the target into ONE short, clear, standalone query. Resolve pronouns and
|
||
restore omitted context from earlier queries. Keep the same language. No time words
|
||
in the rewrite (time goes in SINCE).
|
||
S) SINCE: if the target refers to a time range (e.g. "yesterday", "last week",
|
||
"刚才", "上个月"), output the START date as YYYY-MM-DD. If none, output NONE.
|
||
|
||
Output STRICTLY these lines, nothing else:
|
||
{output_lines}
|
||
|
||
Queries:
|
||
{queries_block}
|
||
"""
|
||
|
||
|
||
async def analyze_queries(
|
||
queries: List[str],
|
||
llm_provider: LLMProvider,
|
||
*,
|
||
want_classify: bool,
|
||
want_rewrite: bool,
|
||
current_time: str = "",
|
||
max_tokens: int = 220,
|
||
temperature: Optional[float] = None,
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
单次 LLM:按需 (判类) + 改写 + 时间跨度。
|
||
|
||
Args:
|
||
want_classify: 是否要 is_coding(working memory 开启时)
|
||
want_rewrite: 是否要 rewrite + timespan(query rewrite 开启时)
|
||
current_time: 系统当前时间(ISO),用于把相对时间换算成 created_after
|
||
|
||
Returns:
|
||
{
|
||
"scene": str, # chat / working / coding
|
||
"is_coding": bool, # 兼容字段:scene != chat
|
||
"rewrite_query": str, # 失败/未改写时 = queries[-1]
|
||
"created_after": float|None,# SINCE 换算的 unix 秒下界,无则 None
|
||
"since_raw": str, # LLM 原始 SINCE(调试用)
|
||
"ok": bool,
|
||
}
|
||
fail-safe:LLM / 解析失败 → ok=False,is_coding=False,rewrite=target,created_after=None
|
||
"""
|
||
target = queries[-1] if queries else ""
|
||
fallback = {
|
||
"scene": "chat", "is_coding": False, "rewrite_query": target,
|
||
"created_after": None, "since_raw": "", "ok": False,
|
||
}
|
||
if not queries or not want_rewrite:
|
||
# 不需要 rewrite 就不该调这个函数;防御性返回 fallback
|
||
return fallback
|
||
|
||
bounded_queries = _bounded_queries(queries)
|
||
queries_block = "\n".join(
|
||
f"- {q}" + (" <-- target" if i == len(bounded_queries) - 1 else "")
|
||
for i, q in enumerate(bounded_queries)
|
||
)
|
||
|
||
if want_classify:
|
||
classify_clause = ""
|
||
task_lines = (
|
||
"C) Classify the target as chat, working, or coding.\n"
|
||
" coding: code/scripts/SQL/notebooks/tests/builds/APIs/debugging are\n"
|
||
" the work object or implementation medium.\n"
|
||
" working: concrete non-code productivity work such as documents,\n"
|
||
" PPT/spreadsheets, finance/business analysis, research deliverables,\n"
|
||
" email/calendar/project work, content/design, file/device/system ops.\n"
|
||
" chat: casual/personal/general Q&A, including incidental weather/news/\n"
|
||
" restaurant/celebrity web searches with no productivity deliverable.\n"
|
||
" Tool use alone never decides the scene.\n"
|
||
)
|
||
output_lines = "<chat, working, or coding>\n<rewritten query>\nSINCE: <YYYY-MM-DD or NONE>"
|
||
else:
|
||
classify_clause = ""
|
||
task_lines = ""
|
||
output_lines = "<rewritten query>\nSINCE: <YYYY-MM-DD or NONE>"
|
||
|
||
prompt = ANALYZE_QUERIES_PROMPT.format(
|
||
classify_clause=classify_clause,
|
||
current_time=current_time or "(unknown)",
|
||
task_lines=task_lines,
|
||
output_lines=output_lines,
|
||
queries_block=queries_block,
|
||
)
|
||
|
||
if temperature is None:
|
||
temperature = _resolve_llm_temperature(llm_provider)
|
||
|
||
try:
|
||
resp = await llm_provider.complete(
|
||
prompt=prompt, max_tokens=max_tokens, temperature=temperature,
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"[coding-judge] analyze_queries LLM failed: {e}; fallback")
|
||
return fallback
|
||
|
||
text = (resp.content or "").strip()
|
||
if not text:
|
||
return fallback
|
||
|
||
# 去 markdown fence
|
||
if text.startswith("```"):
|
||
body = [ln for ln in text.split("\n") if not ln.startswith("```")]
|
||
text = "\n".join(body).strip() or text
|
||
|
||
lines = [ln for ln in text.split("\n") if ln.strip()]
|
||
if not lines:
|
||
return fallback
|
||
|
||
scene = "chat"
|
||
idx = 0
|
||
if want_classify:
|
||
head = lines[0].strip().rstrip(",.").rstrip("。,")
|
||
parsed_scene = _parse_scene_label(head)
|
||
if parsed_scene is None:
|
||
logger.warning(
|
||
f"[coding-judge] analyze_queries unrecognized head={head!r}; ok=False"
|
||
)
|
||
return {**fallback, "since_raw": ""}
|
||
scene = parsed_scene
|
||
idx = 1
|
||
|
||
# 找 SINCE 行;其余行拼成 rewrite
|
||
since_raw = ""
|
||
rewrite_lines: List[str] = []
|
||
for ln in lines[idx:]:
|
||
s = ln.strip()
|
||
if s.upper().startswith("SINCE:"):
|
||
since_raw = s[len("SINCE:"):].strip()
|
||
else:
|
||
rewrite_lines.append(s)
|
||
rewrite = " ".join(rewrite_lines).strip() or target
|
||
|
||
created_after = _since_to_created_after(since_raw, current_time)
|
||
|
||
logger.info(
|
||
f"[coding-judge] analyze scene={scene} rewrite={rewrite!r} "
|
||
f"since={since_raw!r} created_after={created_after}"
|
||
)
|
||
return {
|
||
"scene": scene, "is_coding": scene != "chat", "rewrite_query": rewrite,
|
||
"created_after": created_after, "since_raw": since_raw, "ok": True,
|
||
}
|
||
|
||
|
||
def _since_to_created_after(since_raw: str, current_time: str) -> Optional[float]:
|
||
"""把 LLM 输出的 SINCE(YYYY-MM-DD 或 NONE)换算成 unix 秒下界。"""
|
||
if not since_raw:
|
||
return None
|
||
s = since_raw.strip().strip('"').strip("'").rstrip(".,。,")
|
||
if not s or s.upper() in ("NONE", "N/A", "NULL", "-"):
|
||
return None
|
||
from datetime import datetime
|
||
# 只取前 10 个字符(YYYY-MM-DD),容忍 LLM 带时间
|
||
candidate = s[:10]
|
||
for fmt in ("%Y-%m-%d", "%Y/%m/%d"):
|
||
try:
|
||
dt = datetime.strptime(candidate, fmt)
|
||
return dt.timestamp()
|
||
except (ValueError, TypeError):
|
||
continue
|
||
logger.warning(f"[coding-judge] analyze_queries unparsable SINCE={since_raw!r}")
|
||
return None
|
||
|
||
|
||
# ================================================================
|
||
# Helpers
|
||
# ================================================================
|
||
|
||
def _format_turns(summary: List[Dict[str, Any]]) -> str:
|
||
"""渲染 turn 简化视图给 LLM 看。"""
|
||
if not summary:
|
||
return "(empty)"
|
||
if len(summary) > _JUDGE_MAX_TURNS:
|
||
summary = (
|
||
summary[:_JUDGE_HEAD_TURNS]
|
||
+ [{"turn": "...", "user": "[middle turns omitted]", "tools": []}]
|
||
+ summary[-(_JUDGE_MAX_TURNS - _JUDGE_HEAD_TURNS - 1):]
|
||
)
|
||
rows = []
|
||
for t in summary:
|
||
user = t.get("user", "") or ""
|
||
if len(user) > _JUDGE_USER_CONTENT_LIMIT:
|
||
omitted = len(user) - _JUDGE_USER_CONTENT_HEAD - _JUDGE_USER_CONTENT_TAIL
|
||
user = (
|
||
user[:_JUDGE_USER_CONTENT_HEAD]
|
||
+ f"\n[...{omitted} chars omitted...]\n"
|
||
+ user[-_JUDGE_USER_CONTENT_TAIL:]
|
||
)
|
||
tools = t.get("tools", []) or []
|
||
rows.append(
|
||
f"- turn {t.get('turn', '?')}: user={user!r}, tools={tools}"
|
||
+ (", has_tool_result=True" if t.get("has_tool_result") else "")
|
||
)
|
||
return "\n".join(rows)
|
||
|
||
|
||
def _bounded_queries(queries: List[str]) -> List[str]:
|
||
"""给快速 search judge 的有界 query 视图;始终保留 target query。"""
|
||
bounded = [str(q)[:_JUDGE_MAX_QUERY_CHARS] for q in queries]
|
||
if len(bounded) <= _JUDGE_MAX_QUERIES:
|
||
return bounded
|
||
head = bounded[:_JUDGE_HEAD_TURNS]
|
||
tail = bounded[-(_JUDGE_MAX_QUERIES - _JUDGE_HEAD_TURNS - 1):]
|
||
return head + ["[middle queries omitted]"] + tail
|
||
|
||
|
||
def _parse_scene_label(value: Any) -> Optional[str]:
|
||
"""解析三分类标签,并兼容旧二分类的 0/1 与 true/false 输出。"""
|
||
raw = str(value or "").strip().strip('"').strip("'").lower()
|
||
raw = raw.rstrip(",.,。")
|
||
if raw in ("chat", "working", "coding"):
|
||
return raw
|
||
if raw in ("1", "true", "yes", "y") or raw.startswith("1 "):
|
||
# 旧协议只有 productivity/chat;productivity 对应历史默认 working variant。
|
||
return "working"
|
||
if raw in ("0", "false", "no", "n") or raw.startswith("0 "):
|
||
return "chat"
|
||
return None
|
||
|
||
|
||
def _parse_scene_json(text: str) -> Optional[Dict[str, Any]]:
|
||
"""宽松解析 {scene: ...},并兼容旧 {is_coding: bool}。"""
|
||
if not text:
|
||
return None
|
||
text = text.strip()
|
||
if text.startswith("```"):
|
||
text = "\n".join(
|
||
line for line in text.split("\n") if not line.startswith("```")
|
||
).strip()
|
||
obj = None
|
||
try:
|
||
obj = json.loads(text)
|
||
except Exception:
|
||
start, end = text.find("{"), text.rfind("}")
|
||
if start != -1 and end > start:
|
||
try:
|
||
obj = json.loads(text[start:end + 1])
|
||
except Exception:
|
||
obj = None
|
||
if not isinstance(obj, dict):
|
||
# query 快速路径可能只回一个裸标签。
|
||
scene = _parse_scene_label(text.splitlines()[0] if text else "")
|
||
return {"scene": scene} if scene else None
|
||
scene = _parse_scene_label(obj.get("scene"))
|
||
if scene is None and "is_coding" in obj:
|
||
scene = "working" if bool(obj.get("is_coding")) else "chat"
|
||
if scene is None:
|
||
return None
|
||
return {**obj, "scene": scene}
|
||
|
||
|
||
def _parse_is_coding_json(text: str) -> Optional[Dict[str, Any]]:
|
||
"""
|
||
宽松解析 LLM 输出。先试纯 JSON,再试在文本里找第一个 {...} 块。
|
||
返回 None 表示无法解析。
|
||
"""
|
||
if not text:
|
||
return None
|
||
raw = text.strip()
|
||
if raw.startswith("```"):
|
||
raw = "\n".join(
|
||
line for line in raw.split("\n") if not line.startswith("```")
|
||
).strip()
|
||
obj = None
|
||
try:
|
||
obj = json.loads(raw)
|
||
except Exception:
|
||
start, end = raw.find("{"), raw.rfind("}")
|
||
if start != -1 and end > start:
|
||
try:
|
||
obj = json.loads(raw[start:end + 1])
|
||
except Exception:
|
||
obj = None
|
||
if isinstance(obj, dict) and "is_coding" in obj:
|
||
# 保持旧 helper 的精确返回形状;新三分类逻辑走 _parse_scene_json。
|
||
return obj
|
||
parsed = _parse_scene_json(text)
|
||
if parsed is None:
|
||
return None
|
||
return {"is_coding": parsed["scene"] != "chat"}
|