Skip to content

Commit 396de3e

Browse files
authored
feat: 完成 Study Companion Phase 4 知识追踪与质量候选机制 (#1326)
* 新增了完整的知识追踪链路: - 掌握度追踪 - 每次答题后根据正确率、稳定性、难度、置信度计算 topic mastery。 - 支持识别“假掌握”:看似正确率高,但表现波动大。 - 数据写入 mastery_snapshots,可用于后续 UI 展示薄弱知识点。 - 知识图谱 - 新增 topics 正式知识点表。 - 加载 knowledge_graph_seed.json,当前有 127 个中学数学 topic。 - 支持前置依赖检查,出题前能识别 blocker。 - LLM 发现的新知识点不会直接写入正式图谱,而是进入候选区。 - 错题系统 - 判题为 wrong / partial / dont_know 时写入错题。 - 支持错题变体生成。 - 连续多次答对后可自动标记为 resolved。 - 错题的 error_type 会转化为候选 misconception evidence。 - FSRS-like 复习调度 - 新增本地 fsrs_bridge.py,支持 Again / Hard / Good / Easy 评分。 - 维护复习卡片的 due、stability、difficulty、retrievability。 - get_review_queue() 可返回当前需要复习的 topic。 - 当前未接 fsrs-rs Python 绑定,采用本地调度实现。 - 候选知识质量机制 - 新增 KnowledgeQualityStore。 - 支持候选类型: - topic - edge - misconception - question_type - 支持 evidence: - mentioned - used_in_prompt - user_confirmed - answer_improved - user_rejected - conflict_detected - duplicate_detected - review_retained - 候选项会根据 evidence 得分,生命周期为: - candidate - active - trusted - deprecated - 本地匿名知识贡献摘要 - 新增 PublicGraphContributionBuilder。 - 从候选知识和 evidence 生成本地匿名统计。 - 默认不上传,knowledge_contribution_opt_in = false。 - 摘要只保留结构化 ID/key、计数、score bucket 和 outcome。 - 会拒绝 OCR 原文、题目全文、用户答案、expected answer、feedback、LLM reply 等敏感内容。 - 插件 API 扩展 - study_status 新增: - knowledge_quality_summary - anonymous_knowledge_stats_summary - 新增 entry: - study_knowledge_quality_status - study_anonymous_knowledge_preview - study_clear_knowledge_contribution_queue * 1. Prompt / fallback 集中 - 新增 N.E.K.O/config/prompts/prompts_study_companion.py - N.E.K.O/plugin/plugins/study_companion/llm_prompts.py 和 N.E.K.O/plugin/plugins/study_companion/tutor_llm_agent.py 不再内联 Study Companion 的 LLM prompt / fallback 模板。 2. 覆盖率补齐并验证 - 补了 FSRSBridge、KnowledgeQuality、KnowledgeContribution、prompt 压缩、OCR、screen classifier、LLM 参数约束等测试。 - 覆盖率命令通过:uv run --with coverage python -m coverage report --fail-under=80 plugin/plugins/study_companion/*.py - 结果:TOTAL 80%,满足 80% gate。 3. 测试全部改用 uv run - 已用 uv run python -m pytest ... 跑过。 - 说明:直接 uv run pytest 在本机报 uv trampoline failed to canonicalize script path,所以使用了等价的 uv run python -m pytest。 4. async lock 跨 await 修复 - N.E.K.O/plugin/plugins/study_companion__init__.py 中 Tesseract / RapidOCR 下载不再持有 threading.Lock 跨 await,改为在 RLock 内设置 in-progress flag,await 前释放锁。 5. LLM temperature / max tokens 修复 - _call_model() 不再向 create_chat_llm() 显式传 temperature / max_completion_tokens。 - 清掉了 study_companion 自己的 llm temperature/max_tokens 配置和 llm_limits_for_operation() 死代码,避免和备忘录冲突。 验证结果: - uv run python -m pytest ...:study_companion 相关 69 个测试通过。 - coverage gate:80% 通过。 - 静态搜索 study_companion 源码:无 temperature / max_tokens / max_completion_tokens / llm_limits_for_operation 残留。 * 已修复 - knowledge_quality.py - TRUSTED 不再无条件保持。 - 新增可配置项: - evidence_recompute_limit - trusted_negative_threshold - TRUSTED 候选在 user_rejected 或负向 evidence 达到阈值时可降级为 DEPRECATED。 - recompute_score() 不再硬编码 limit=5000,改用可配置上限。 - store.py - 新增 safe_int()。 - load_knowledge_seed()、upsert_topic()、_topic_from_row() 的 depth / difficulty 转换改为容错转换,坏 seed 不会打断启动。 跳过 / 最小化处理 - 没有新增 evidence 分页 API 或归档/删除机制:当前 store 没有现成分页接口,这条是 nitpick,最小修复采用“可配置 recompute limit”,避免扩大改动面。 补充测试 - trusted 候选在配置阈值下可被强负向 evidence 降级。 - trusted 候选被 user_rejected 后可降级。 - 坏 seed / 坏 topic 数值字段不会导致启动或 upsert 崩溃。 - 现有 trusted conflict 测试改为显式使用 trusted_negative_threshold=1。 验证 uv run python -m pytest plugin/tests/unit/plugins/test_study_companion_knowledge_quality.py plugin/tests/unit/plugins/test_study_companion_knowledge_tracker.py -q # 11 passed uv run python -m pytest @files -q # 73 passed @files 为 plugin/tests/unit/plugins/test_study_companion*.py。 * fix: 修复边类型知识点过滤及未知主题答题处理 - 重构知识点过滤逻辑,正确匹配 EDGE 类型候选的 from/to_topic_id - 未录入主题答题时使用空 topic_id,避免外键约束失败 - 启用 SQLite 外键约束并规范化 topic_key 存储 - 新增相关单元测试 * fix: 修复难度整数未归一化及错题匹配逻辑 - 将1-5的整数难度值正确缩放至0.1-1.0范围 - 优先匹配重试中的错题,且通用正确回答仅推进一条错题 * 主要改动 - N.E.K.O/plugin/plugins/study_companion/store.py:16 - 新增 append-only 表的滚动修剪逻辑,默认每组保留最新 5000 条。 - 对 mastery_snapshots、qa_records、review_log、knowledge_evidence 写入后按 topic_id 或 item_id 修剪。 - 新增 count_tracked_mastery_topics(),用于统计所有已有 mastery 记录的 topic 数。 - list_candidate_items() 支持按 topic_id 过滤候选知识项。 - N.E.K.O/plugin/plugins/study_companion/knowledge_quality.py:166 - list_candidates() 增加 topic_id 参数。 - prompt_evidence_summary() 现在先按 topic 过滤,再应用 limit。 - _candidate_matches_topic() 不再把没有 topic 的候选当作匹配当前 topic。 - N.E.K.O/plugin/plugins/study_companion/knowledge_tracker.py:457 - tracked_topic_count 改为用 count_tracked_mastery_topics(),避免被 overview 的 limit 截断。 测试改动 - N.E.K.O/plugin/tests/unit/plugins/test_study_companion_knowledge_tracker.py:89 - 新增 append-only 表按 key 修剪的回归测试。 - test_knowledge_seed_loads_idempotently 去掉脆弱的 120 <= count <= 150,改为 first_count > 0 加幂等性断言。 - N.E.K.O/plugin/tests/unit/plugins/test_study_companion.py:252 - 新增测试确认 tracked_topic_count 不受 list_mastery_overview(limit=...) 限制。 - N.E.K.O/plugin/tests/unit/plugins/test_study_companion_knowledge_quality.py:176 - 新增测试确认 prompt evidence summary 会先按 topic 过滤再 limit。 已验证 - .venv\Scripts\python.exe -m pytest plugin/tests/unit/plugins/test_study_companion_knowledge_tracker.py - 结果:9 passed - git diff --check 通过。 * - 原来外溢在 config/prompts/prompts_study_companion.py 的 Study Companion prompt 模板,已移动到插件内: - plugin/plugins/study_companion/prompt_templates.py - 两个引用点已改为插件内相对导入: - plugin/plugins/study_companion/llm_prompts.py - plugin/plugins/study_companion/tutor_llm_agent.py - 全局文件现在是删除状态: - config/prompts/prompts_study_companion.py 验证也过了: - compileall plugin/plugins/study_companion - test_study_companion.py:50 passed * - get_status_summary() 的 average_mastery 改为调用 store 的全量最新掌握度平均值,而不是只按当前 overview limit 计算。 - store.py - 新增 average_latest_mastery()。 - 它按每个 topic 最新一条 mastery_snapshots 计算全量平均掌握度。 - test_study_companion.py - 扩展状态汇总测试:构造 12 个 topic,验证 tracked_topic_count 不受 limit 影响,同时验证 average_mastery == 0.6667,确认平均值来自全量 topic 而不是 limit=8 的截断结果。 - test_study_companion_knowledge_tracker.py - 补充 1.0、"1.0"、3.0、5.0 的难度归一化断言。 - 将错题难度证据测试从 difficulty=1 改成 difficulty=1.0,覆盖实际 JSON float 场景。 已验证过: - python -m compileall plugin\plugins\study_companion\knowledge_tracker.py - uv run python -m pytest plugin/tests/unit/plugins/test_study_companion_knowledge_tracker.py -q - 全量 study companion 单测:81 passed, 1 warning * - N.E.K.O/plugin/plugins/study_companion/knowledge_tracker.py - 新增 count_weak_topics():按全量 mastery overview 统计弱项数量。 - 新增 count_due_reviews():按全量 FSRS cards 统计 due review 数量。 - get_status_summary() 改为使用这两个真实计数。 - get_weak_topics(limit=...) 和 get_review_queue(limit=...) 仍保持展示列表限制,不影响 UI 列表大小。 - 测试 - N.E.K.O/plugin/tests/unit/plugins/test_study_companion.py:验证 weak_topic_count 不被展示 limit 截断。 - N.E.K.O/plugin/tests/unit/plugins/test_study_companion_knowledge_tracker.py:新增 due review 计数测试,12 个 due cards 时 get_review_queue(limit=8) 返回 8,但 due_review_count 返回 12。 验证通过: - python -m compileall plugin\plugins\study_companion\knowledge_tracker.py - 定向测试:62 passed, 1 warning - 全量 study companion 单测:84 passed, 1 warning * - plugin/plugins/study_companion/store.py - 修复 topic 数值字段读取/写入时的默认值逻辑: - 原来是 safe_int(value or 1, 1) / safe_float(value or 0.5, 0.5) - 现在是 safe_int(value, 1) / safe_float(value, 0.5) - 影响位置: - _topic_from_row() - load_knowledge_seed() - upsert_topic() - 目的:保留合法的 0 / 0.0,不再因为 falsy 被替换成默认值。 - 修复 record_wrong_question_correct(): - 新增 processed_error_types - 同一次正确答题只推进每个 error_type 的一条 wrong question - 保留原有 generic ""/"none" 只推进一条的行为。 - plugin/tests/unit/plugins/test_study_companion_knowledge_tracker.py - 新增测试:同一 error_type 的两条 sibling wrong questions,只推进一条。 - 扩展数值容错测试:depth=0、difficulty=0.0 会被保留。 已验证过: - python -m compileall plugin\plugins\study_companion\store.py - 定向测试:13 passed, 1 warning - 全量 study companion 单测:85 passed, 1 warning * - knowledge_contribution.py - 新增 _anonymous_topic_key()。 - 匿名知识贡献 payload 中的 topic 引用现在统一转成 topic:<hash>。 - 覆盖: - topic candidate 的 topic_id - edge 的 from_topic_id / to_topic_id - misconception 的 topic_id - question type 的 topic_id - 目的:避免从用户文本 slug 生成的 topic_id 泄露到 anonymous stats / queue。 - test_study_companion_knowledge_contribution.py - 新增测试:构造包含 alice_private_calculus_goal、bob_secret_prerequisite 的候选项,确认匿名统计里不出现原始 topic 文本,只出现 topic: hash key。 - store.py - 增强 export_json() 的导出完整性: - 新增导出 sessions - 新增导出 qa_records - 新增导出 review_log - 新增导出 knowledge_evidence - 新增对应 list/helper: - list_sessions() - list_qa_records() - _qa_record_from_row() - list_review_log() - list_knowledge_evidence() 支持不传 item_id 时列出全部 evidence - 这部分看起来是另一个 review 修复:让 store 的 JSON 导出包含 Phase 4 新增的学习记录/证据表,不只导出 summary 表。 - test_study_companion.py - 扩展 test_study_store_round_trip_and_export: - 写入 session、QA record、review log、knowledge evidence - 验证 export_json() 包含这些新增表数据。 * 1. 清理误入 PR 的 pytest 临时库 这 3 个是删除状态,用来把已跟踪的测试产物从 PR 里移除: D .pytest-run-qa-trim-codex/test_add_qa_record_trims_unkno0/study.db D .pytest-run-qa-trim-codex/test_append_only_knowledge_tab0/study.db D .pytest-run-qa-trim-codex/test_unknown_topic_answer_reco0/study.db 2. study_companion 运行时代码修改 N.E.K.O/plugin/plugins/study_companion/knowledge_tracker.py: 新增 _difficulty_to_level(),把难度统一归一到 1-5 档;错题纠正和 difficulty key 都改用这个函数。 N.E.K.O/plugin/plugins/study_companion/store.py: 调整 append-only 查询为“取最新 N 条再按旧到新返回”;seed topic 批量导入时减少重复 commit;knowledge_contribution_queue 增加按 status 裁剪历史的逻辑。 3. 测试修改 N.E.K.O/plugin/tests/unit/plugins/test_study_companion.py: 补了 QA、review log、knowledge evidence 最新记录返回顺序的断言。 N.E.K.O/plugin/tests/unit/plugins/test_study_companion_knowledge_contribution.py: 新增 contribution queue 按 status 裁剪的测试。 N.E.K.O/plugin/tests/unit/plugins/test_study_companion_knowledge_tracker.py: 新增 difficulty level 转换测试,以及默认中等难度也能正确解析错题的测试。 * fix: 修复自定义问题复用旧题目数据及复习队列截断问题 - 自定义题目评估时不再错误继承当前题目的主题等字段 - 移除 FSRS 卡片和到期复习的数量上限,避免大量卡片时遗漏 - 调整错题重试排序优先级,确保重试中的题目优先被选中 - 修复空 topic_id 查询 QA 记录的条件匹配 * feat(study_companion): propagate session_id and run_id through evaluation pipeline - Extract session_id from context with fallback chain for knowledge tracking - Pass run_id and session_id to learning context in evaluate_answer - Update _resolve_current_run_id to check extra_args first - Preserve seed topic fields on conflict in store upsert
1 parent 1e5e238 commit 396de3e

18 files changed

Lines changed: 8772 additions & 326 deletions

plugin/plugins/study_companion/__init__.py

Lines changed: 159 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import asyncio
4+
from pathlib import Path
45
import threading
56
import time
67
from typing import Any
@@ -37,6 +38,8 @@
3738
build_tutor_payload,
3839
)
3940
from .mode_manager import ModeManager, build_transition_phrase, handle_user_intent, normalize_mode
41+
from .knowledge_contribution import PublicGraphContributionBuilder
42+
from .knowledge_tracker import KnowledgeTracker
4043
from .state import build_initial_state
4144
from .store import StudyStore
4245
from .study_ocr_pipeline import StudyOcrPipeline
@@ -52,18 +55,24 @@ def __init__(self, ctx):
5255
self.file_logger = self.enable_file_logging(log_level="INFO")
5356
self.logger = self.file_logger
5457
self._lock = threading.RLock()
55-
self._install_lock = threading.Lock()
56-
self._rapidocr_models_lock = threading.Lock()
58+
self._install_in_progress = False
59+
self._rapidocr_models_in_progress = False
5760
self._cfg = StudyConfig()
5861
self._state = build_initial_state(mode=MODE_COMPANION)
5962
self._store = StudyStore(
6063
self.data_path("study_companion.db"),
6164
self.config_dir / "data" / "study_seed.json",
6265
self.logger,
66+
Path(__file__).resolve().parent / "static" / "knowledge_graph_seed.json",
6367
)
6468
self._ocr_pipeline: StudyOcrPipeline | None = None
6569
self._agent: TutorLLMAgent | None = None
6670
self._mode_manager = ModeManager()
71+
self._knowledge_tracker = KnowledgeTracker(
72+
self._store,
73+
retention_target=self._cfg.fsrs_retention_target,
74+
logger=self.logger,
75+
)
6776

6877
@lifecycle(id="startup")
6978
async def startup(self, **_):
@@ -72,6 +81,11 @@ async def startup(self, **_):
7281
self._cfg = build_config(raw if isinstance(raw, dict) else {})
7382
await asyncio.to_thread(self._store.open)
7483
self._cfg = await asyncio.to_thread(self._store.load_config, self._cfg)
84+
self._knowledge_tracker = KnowledgeTracker(
85+
self._store,
86+
retention_target=self._cfg.fsrs_retention_target,
87+
logger=self.logger,
88+
)
7589
restored = await asyncio.to_thread(self._store.load_state, build_initial_state(mode=self._cfg.mode))
7690
with self._lock:
7791
self._state = restored
@@ -201,7 +215,15 @@ async def _apply_mode_switch(self, mode: str, reason: str, *, language: str | No
201215

202216
def _status_payload(self) -> dict[str, Any]:
203217
history = self._store.list_interactions(limit=10)
204-
return build_status_payload(config=self._cfg, state=self._state, history=history)
218+
knowledge = {
219+
"knowledge_summary": self._knowledge_tracker.get_status_summary(limit=8),
220+
"knowledge_quality_summary": self._knowledge_tracker.quality.status_summary(limit=8),
221+
"anonymous_knowledge_stats_summary": self._store.anonymous_knowledge_stats_summary(),
222+
"review_queue": self._knowledge_tracker.get_review_queue(limit=8),
223+
"weak_topics": self._knowledge_tracker.get_weak_topics(limit=8),
224+
"mastery_overview": self._store.list_mastery_overview(limit=8),
225+
}
226+
return build_status_payload(config=self._cfg, state=self._state, history=history, knowledge=knowledge)
205227

206228
def _state_snapshot(self) -> dict[str, Any]:
207229
with self._lock:
@@ -299,6 +321,23 @@ async def _build_learning_context(
299321
"last_ocr_at": snapshot.get("last_ocr_at") or "",
300322
"history": history,
301323
}
324+
if operation == LLM_OPERATION_QUESTION_GENERATE:
325+
hint = ""
326+
if extra:
327+
hint = str(extra.get("topic_hint") or extra.get("topic") or "").strip()
328+
context["knowledge_question_params"] = await asyncio.to_thread(
329+
self._knowledge_tracker.get_next_question_params,
330+
hint,
331+
)
332+
elif operation == LLM_OPERATION_SUMMARIZE_SESSION:
333+
context["knowledge_session_summary"] = await asyncio.to_thread(
334+
self._knowledge_tracker.get_session_summary
335+
)
336+
else:
337+
context["knowledge_summary"] = await asyncio.to_thread(
338+
self._knowledge_tracker.get_status_summary,
339+
limit=5,
340+
)
302341
if extra:
303342
context.update(extra)
304343
return context
@@ -404,6 +443,56 @@ async def _track_learning(
404443
created_at=utc_now_iso(),
405444
)
406445
self._record_tutor_result(LLM_OPERATION_KNOWLEDGE_TRACK, track_reply)
446+
if operation == LLM_OPERATION_ANSWER_EVALUATE:
447+
await self._record_answer_knowledge(reply, track_reply, extra_context=extra_context)
448+
449+
async def _record_answer_knowledge(
450+
self,
451+
eval_reply: TutorReply,
452+
track_reply: TutorReply,
453+
*,
454+
extra_context: dict[str, Any] | None = None,
455+
) -> None:
456+
context = dict(extra_context or {})
457+
track_payload = dict(track_reply.payload or {})
458+
eval_payload = dict(eval_reply.payload or {})
459+
current_question = dict(context.get("current_question") or {})
460+
question_payload = dict(context.get("question_payload") or current_question)
461+
question_text = str(context.get("question") or question_payload.get("question") or current_question.get("question") or "").strip()
462+
question_payload["question"] = question_text
463+
question_payload["answer"] = str(context.get("expected_answer") or question_payload.get("answer") or current_question.get("answer") or "")
464+
topic = str(
465+
question_payload.get("topic")
466+
or track_payload.get("topic")
467+
or eval_payload.get("topic")
468+
or self._guess_track_topic(track_reply)
469+
).strip()
470+
if topic:
471+
question_payload.setdefault("topic", topic)
472+
eval_result = {
473+
**eval_payload,
474+
"topic": topic,
475+
"track": track_payload,
476+
}
477+
session_id = str(
478+
context.get("session_id")
479+
or context.get("run_id")
480+
or getattr(self._state, "run_id", "")
481+
or getattr(self.ctx, "run_id", "")
482+
or "default"
483+
).strip() or "default"
484+
try:
485+
await asyncio.to_thread(
486+
self._knowledge_tracker.on_answer,
487+
topic_id=topic,
488+
question=question_payload,
489+
user_answer=str(context.get("answer") or eval_reply.input_text or ""),
490+
eval_result=eval_result,
491+
mode=str(context.get("mode") or self._state.active_mode),
492+
session_id=session_id,
493+
)
494+
except Exception as exc:
495+
self.logger.warning("study knowledge tracker persistence failed: {}", exc)
407496

408497
@staticmethod
409498
def _guess_track_topic(reply: TutorReply) -> str:
@@ -416,6 +505,10 @@ def _guess_track_topic(reply: TutorReply) -> str:
416505
return first_line[:48] or "general"
417506

418507
def _resolve_current_run_id(self, extra_args: dict[str, Any] | None = None) -> str:
508+
if isinstance(extra_args, dict):
509+
direct = str(extra_args.get("run_id") or "").strip()
510+
if direct:
511+
return direct
419512
current = str(getattr(self.ctx, "run_id", "") or "").strip()
420513
if current:
421514
return current
@@ -470,6 +563,41 @@ async def study_status(self, **_):
470563
payload = await asyncio.to_thread(self._status_payload)
471564
return Ok(payload)
472565

566+
@plugin_entry(
567+
id="study_knowledge_quality_status",
568+
name=tr("entries.knowledge_quality_status.name", default="Study Knowledge Quality Status"),
569+
description=tr("entries.knowledge_quality_status.description", default="Return candidate knowledge quality counts and recent evidence."),
570+
input_schema={"type": "object", "properties": {"limit": {"type": "integer", "default": 20}}},
571+
llm_result_fields=["total", "by_status", "recent_evidence"],
572+
)
573+
async def study_knowledge_quality_status(self, limit: int = 20, **_):
574+
payload = await asyncio.to_thread(self._knowledge_tracker.quality.status_summary, limit=max(1, int(limit or 20)))
575+
return Ok(payload)
576+
577+
@plugin_entry(
578+
id="study_anonymous_knowledge_preview",
579+
name=tr("entries.anonymous_knowledge_preview.name", default="Study Anonymous Knowledge Preview"),
580+
description=tr("entries.anonymous_knowledge_preview.description", default="Build and return a local anonymized knowledge contribution preview. Phase 4 does not upload it."),
581+
input_schema={"type": "object", "properties": {"limit": {"type": "integer", "default": 100}}},
582+
llm_result_fields=["summary", "stats", "opt_in"],
583+
)
584+
async def study_anonymous_knowledge_preview(self, limit: int = 100, **_):
585+
builder = PublicGraphContributionBuilder(self._store, self._cfg)
586+
payload = await asyncio.to_thread(builder.preview, limit=max(1, int(limit or 100)))
587+
return Ok(payload)
588+
589+
@plugin_entry(
590+
id="study_clear_knowledge_contribution_queue",
591+
name=tr("entries.clear_knowledge_contribution_queue.name", default="Clear Study Knowledge Contribution Queue"),
592+
description=tr("entries.clear_knowledge_contribution_queue.description", default="Clear the local anonymous knowledge contribution queue."),
593+
input_schema={"type": "object", "properties": {}},
594+
llm_result_fields=["cleared_count"],
595+
)
596+
async def study_clear_knowledge_contribution_queue(self, **_):
597+
builder = PublicGraphContributionBuilder(self._store, self._cfg)
598+
cleared = await asyncio.to_thread(builder.clear_queue)
599+
return Ok({"cleared_count": cleared})
600+
473601
@plugin_entry(
474602
id="study_detect_mode_intent",
475603
name=tr("entries.detect_mode_intent.name", default="Detect Study Mode Intent"),
@@ -690,7 +818,7 @@ async def study_generate_question(self, text: str = "", topic: str = "", **_):
690818
timeout=60.0,
691819
llm_result_fields=["summary", "verdict", "score", "error_type", "feedback", "next_action"],
692820
)
693-
async def study_evaluate_answer(self, answer: str = "", question: str = "", expected_answer: str = "", **_):
821+
async def study_evaluate_answer(self, answer: str = "", question: str = "", expected_answer: str = "", **kwargs):
694822
if self._agent is None:
695823
return Err(SdkError("study tutor agent is not initialized"))
696824
with self._lock:
@@ -707,14 +835,28 @@ async def study_evaluate_answer(self, answer: str = "", question: str = "", expe
707835
if not resolved_expected and (not supplied_question or supplied_question == state_question):
708836
resolved_expected = state_expected
709837
answer_text = str(answer or "").strip()
838+
using_current_question = not supplied_question or supplied_question == state_question
839+
question_payload = dict(current_question) if using_current_question else {}
840+
question_payload.update(
841+
{
842+
"question": resolved_question,
843+
"answer": resolved_expected,
844+
}
845+
)
846+
run_id = self._resolve_current_run_id(kwargs)
847+
session_id = str(kwargs.get("session_id") or "").strip()
710848
tutor_context = await self._build_learning_context(
711849
LLM_OPERATION_ANSWER_EVALUATE,
712850
input_text=answer_text,
713851
extra={
714852
"question": resolved_question,
715853
"expected_answer": resolved_expected,
716854
"answer": answer_text,
717-
"current_question": current_question,
855+
"current_question": current_question if using_current_question else {},
856+
"question_payload": question_payload,
857+
"question_source": "current_question" if using_current_question else "supplied",
858+
"run_id": run_id,
859+
"session_id": session_id,
718860
"mode": active_mode,
719861
},
720862
)
@@ -790,8 +932,10 @@ async def study_summarize_session(self, focus: str = "", **_):
790932
llm_result_fields=["summary"],
791933
)
792934
async def study_install_tesseract(self, force: bool = False, **kwargs):
793-
if not self._install_lock.acquire(blocking=False):
794-
return Err(SdkError("Tesseract install is already running"))
935+
with self._lock:
936+
if self._install_in_progress:
937+
return Err(SdkError("Tesseract install is already running"))
938+
self._install_in_progress = True
795939
run_id = self._resolve_current_run_id(kwargs)
796940
try:
797941
from plugin.plugins.galgame_plugin.tesseract_support import install_tesseract
@@ -814,7 +958,8 @@ async def study_install_tesseract(self, force: bool = False, **kwargs):
814958
except Exception as exc:
815959
return Err(SdkError(f"Tesseract install failed: {exc}"))
816960
finally:
817-
self._install_lock.release()
961+
with self._lock:
962+
self._install_in_progress = False
818963

819964
@plugin_entry(
820965
id="study_download_rapidocr_models",
@@ -825,8 +970,10 @@ async def study_install_tesseract(self, force: bool = False, **kwargs):
825970
llm_result_fields=["summary"],
826971
)
827972
async def study_download_rapidocr_models(self, force: bool = False, **kwargs):
828-
if not self._rapidocr_models_lock.acquire(blocking=False):
829-
return Err(SdkError("RapidOCR model download is already running"))
973+
with self._lock:
974+
if self._rapidocr_models_in_progress:
975+
return Err(SdkError("RapidOCR model download is already running"))
976+
self._rapidocr_models_in_progress = True
830977
run_id = self._resolve_current_run_id(kwargs)
831978
try:
832979
from plugin.plugins.galgame_plugin.rapidocr_support import download_rapidocr_models
@@ -859,7 +1006,8 @@ async def study_download_rapidocr_models(self, force: bool = False, **kwargs):
8591006
except Exception as exc:
8601007
return Err(SdkError(f"RapidOCR model download failed: {exc}"))
8611008
finally:
862-
self._rapidocr_models_lock.release()
1009+
with self._lock:
1010+
self._rapidocr_models_in_progress = False
8631011

8641012

8651013
StudyCompanionBridgePlugin = StudyCompanionPlugin

0 commit comments

Comments
 (0)