From e7ff07debdcc3f0f440935eccbf95ffdb769c3fc Mon Sep 17 00:00:00 2001 From: Administrator Date: Mon, 8 Jun 2026 21:50:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(knowledge):=20=E9=A1=B9=E7=9B=AE=E5=90=8D?= =?UTF-8?q?=E6=94=B9=20zncm=20+=20=E5=8E=BB=E9=99=A4=E5=85=83=E9=93=BE?= =?UTF-8?q?=E6=8E=A5=E5=99=AA=E9=9F=B3=20+=20=E5=88=9B=E5=BB=BA=E4=BA=BA?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E4=BA=BA=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - project_name 默认 deerflow → zncm(config.yaml + 配置默认 + 模块内字面量) - related_links() 返回空:笔记 Related 不再注入 index/_meta/taxonomy/hub 结构链接, 只保留真实相关笔记的可点 [[标题]];无相关笔记且无来源时不再渲染 ## Related 段 - 移除随之多余的项目 hub 页脚手架(index.md/_meta/taxonomy.md 仍由 obsidian 脚手架保留,可解析) - GET /notes/{id} 用 auth provider 把 created_by/updated_by(UUID) 解析为邮箱 (created_by_name/updated_by_name),前端 footer 显示人名、解析不到才显示短 id - 仅对新沉淀笔记生效,现有笔记不动 Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend-web/src/core/knowledge/types.ts | 2 + frontend-web/src/pages/KnowledgeBasePage.tsx | 10 +++- offline-backend-20260512/backend/CLAUDE.md | 6 +-- .../backend/app/gateway/routers/knowledge.py | 28 ++++++++++- offline-backend-20260512/backend/config.yaml | 2 +- .../deerflow/config/knowledge_config.py | 2 +- .../harness/deerflow/knowledge/extractor.py | 50 ++++++++++--------- .../deerflow/knowledge/llm_extractor.py | 17 +++---- .../deerflow/knowledge/markdown_vault.py | 15 ------ .../deerflow/knowledge/obsidian_adapter.py | 2 +- .../harness/deerflow/knowledge/service.py | 10 ++-- 11 files changed, 83 insertions(+), 61 deletions(-) diff --git a/frontend-web/src/core/knowledge/types.ts b/frontend-web/src/core/knowledge/types.ts index ec062967..ee05a5fb 100644 --- a/frontend-web/src/core/knowledge/types.ts +++ b/frontend-web/src/core/knowledge/types.ts @@ -32,6 +32,8 @@ export interface KnowledgeNote { confidence?: number; created_by?: string | null; updated_by?: string | null; + created_by_name?: string | null; + updated_by_name?: string | null; created_at?: string | null; updated_at?: string | null; tags: string[]; diff --git a/frontend-web/src/pages/KnowledgeBasePage.tsx b/frontend-web/src/pages/KnowledgeBasePage.tsx index 454ec784..a16ed2f1 100644 --- a/frontend-web/src/pages/KnowledgeBasePage.tsx +++ b/frontend-web/src/pages/KnowledgeBasePage.tsx @@ -65,6 +65,13 @@ const STATUS_LABELS: Record = { archived: "已归档", }; +/** Human label for an audit actor: prefer the resolved name (email), else a short id. */ +function actorLabel(name?: string | null, id?: string | null): string { + if (name && name.trim()) return name.trim(); + if (id && id.trim()) return `${id.trim().slice(0, 8)}…`; + return "—"; +} + function useDebounced(value: T, delay = 350): T { const [debounced, setDebounced] = useState(value); useEffect(() => { @@ -593,7 +600,8 @@ function NoteDetail({ noteId }: { noteId: string }) { ) : null}
- 创建人 {note.created_by ?? "—"} · 更新人 {note.updated_by ?? "—"} · 更新于{" "} + 创建人 {actorLabel(note.created_by_name, note.created_by)} · 更新人{" "} + {actorLabel(note.updated_by_name, note.updated_by)} · 更新于{" "} {note.updated_at?.slice(0, 19).replace("T", " ")}
diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index 3d8cb583..c1b8af5b 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -284,9 +284,9 @@ Self-contained product knowledge base. **All knowledge capability lives in this **Module layout:** `models.py` (ORM: notes/sources/tags/note_tags + embeddings/versions/entities/relations) · `repository.py` (DB only, no LLM) · `schemas.py` (NoteDraft/SourceDraft DTOs) · `skill_templates.py` · `markdown_vault.py` · `manifest.py` · `extractor.py` (rule-based fallback) · `llm_extractor.py` (**default**: distills declarative knowledge per wiki-capture, structured JSON incl. entities/relations; falls back to `extractor.py`) · `sources.py` · `exporter.py` (`graph.json`/`graph.graphml`/`cypher.txt` + self-contained vanilla-JS `graph.html`; entity nodes + relation edges) · `chunking.py` + `embeddings.py` (OpenAI-compatible embedding client, keyword fallback) + `search.py` (keyword/vector/hybrid) · `sensitive.py` (secret/PII redaction) · `dedup.py` (Jaccard + hash dedup) · `auto_ingest.py` (background queue/worker) · `obsidian_adapter.py` · `service.py` (`KnowledgeService` orchestrator — the only thing the router touches). Tests: `tests/test_knowledge.py` (20 cases). Frontend: `frontend-web/src/core/knowledge/*` + `pages/KnowledgeBasePage.tsx` / `KnowledgeGraphPage.tsx` + chat `knowledge-save-trigger.tsx` / `knowledge-rag-toggle.tsx`; spec at `frontend-web/docs/product-knowledge-base-dev.md`. -**Wikilinks & multi-note capture** (`knowledge.{related_notes_enabled,related_notes_limit,multi_note_enabled}`, all default-on): -- **Navigable `[[wikilinks]]`** — `service.resolve_wikilink(s)` maps a `[[target]]` to a knowledge note (by id → `vault_path` → title) or a vault meta/hub page (`read_vault_page`, path-traversal-proof), else `missing`. `markdown_vault.normalize_wikilink_target` canonicalizes targets (drops `|alias`, slashes, `.md`). The frontend `MarkdownRenderer` renders non-numeric `[[...]]` as clickable links from the resolver map (numeric `[[123]]` stay citation badges); note links navigate, vault links open a `VaultPageDialog`, missing links are greyed. The vault scaffold now materializes `projects//synthesis/product-knowledge-base.md` (the hub page every note's `## Related` links to) so the three historically-hardcoded structural links (`[[index]]`, `[[_meta/taxonomy]]`, the hub) always resolve. -- **Real related notes** — on capture, `service._find_related_notes` runs keyword/hybrid search over the draft's title + summary + *distinctive* tags (project/`knowledge-base` defaults excluded) and injects real `[[Title]]` cross-links at the top of `## Related` (plus note→note `related` graph edges), replacing the placeholder-only block. +**Wikilinks & multi-note capture** (`knowledge.{related_notes_enabled,related_notes_limit,multi_note_enabled}`, all default-on; `project_name` defaults to `zncm`): +- **Navigable `[[wikilinks]]`** — `service.resolve_wikilink(s)` maps a `[[target]]` to a knowledge note (by id → `vault_path` → title) or a vault meta page (`read_vault_page`, path-traversal-proof), else `missing`. `markdown_vault.normalize_wikilink_target` canonicalizes targets (drops `|alias`, slashes, `.md`). The frontend `MarkdownRenderer` renders non-numeric `[[...]]` as clickable links from the resolver map (numeric `[[123]]` stay citation badges); note links navigate, vault links open a `VaultPageDialog`, missing links are greyed. +- **Real related notes only (no structural meta-links)** — `extractor.related_links()` now returns `[]`: notes no longer auto-link `index` / `_meta/taxonomy` / a project hub (they were noise in every `## Related`). On capture, `service._find_related_notes` runs keyword/hybrid search over the draft's title + summary + *distinctive* tags (project/`knowledge-base` defaults excluded) and `_inject_related_notes` writes real `[[Title]]` cross-links into `## Related` (plus note→note `related` graph edges); the renderers omit the `## Related` section entirely when there are no related notes and no sources. The vault's own `index.md`/`_meta/taxonomy.md` still exist (obsidian scaffold) and remain resolvable for manually-written links. The router enriches `GET /notes/{id}` with `created_by_name`/`updated_by_name` (auth-provider email lookup) so the UI shows a person, not a UUID. - **Multi-note split** — `llm_extractor.extract_thread_llm_multi` lets one conversation distill into 1–4 cross-linked notes (`notes:[…]` JSON array; legacy single-object output still accepted). `capture_thread` persists each, sibling-cross-links them by title, and returns `{note, created, notes:[…]}`. Gated by `multi_note_enabled` (false → first note only). Tests: `tests/test_knowledge_wikilinks.py`. **Phases 2–4 (all config-gated, defaults safe):** diff --git a/offline-backend-20260512/backend/app/gateway/routers/knowledge.py b/offline-backend-20260512/backend/app/gateway/routers/knowledge.py index ac2d10b9..8b031a31 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/knowledge.py +++ b/offline-backend-20260512/backend/app/gateway/routers/knowledge.py @@ -20,7 +20,7 @@ from fastapi.responses import FileResponse from pydantic import BaseModel, Field from app.gateway.authz import require_auth, require_permission -from app.gateway.deps import get_checkpointer, get_knowledge_service, get_thread_store +from app.gateway.deps import get_checkpointer, get_knowledge_service, get_local_provider, get_thread_store from app.gateway.utils import sanitize_log_param from deerflow.knowledge.service import EmptyThreadError from deerflow.runtime import serialize_channel_values @@ -116,6 +116,31 @@ async def _read_thread_values(checkpointer, thread_id: str) -> dict[str, Any]: return serialize_channel_values(checkpoint.get("channel_values", {}) or {}) +async def _enrich_actor_names(note: dict[str, Any]) -> None: + """Resolve created_by/updated_by user ids to a human label (email). + + Adds ``created_by_name`` / ``updated_by_name`` to the note dict so the UI can + show a person instead of a raw UUID. Best-effort: unresolved ids fall back to + ``None`` and the frontend then shows a shortened id. + """ + provider = get_local_provider() + cache: dict[str, str | None] = {} + + async def name_of(uid: str | None) -> str | None: + if not uid: + return None + if uid not in cache: + try: + user = await provider.get_user(uid) + cache[uid] = user.email if user else None + except Exception: + cache[uid] = None + return cache[uid] + + note["created_by_name"] = await name_of(note.get("created_by")) + note["updated_by_name"] = await name_of(note.get("updated_by")) + + def _messages_and_meta(values: dict[str, Any]) -> tuple[list[dict[str, Any]], str | None, list[dict[str, Any]]]: messages = values.get("messages") if not isinstance(messages, list): @@ -318,6 +343,7 @@ async def get_note(note_id: str, request: Request) -> dict[str, Any]: note = await service.get_note(note_id) if note is None: raise HTTPException(status_code=404, detail="Knowledge note not found") + await _enrich_actor_names(note) return note diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index 575707c1..5ec643d9 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -351,7 +351,7 @@ knowledge: enabled: true provider: obsidian_wiki vault_dir: data/knowledge/obsidian-vault - project_name: deerflow + project_name: zncm auto_ingest_enabled: false auto_ingest_min_chars: 300 # 用大模型按 wiki-capture 规范提炼陈述式知识(而非堆砌对话原文)。 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/knowledge_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/knowledge_config.py index ee9525c1..d30d0c4c 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/knowledge_config.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/knowledge_config.py @@ -54,7 +54,7 @@ class KnowledgeConfig(BaseModel): default="data/knowledge/obsidian-vault", description="Vault directory (relative to DEER_FLOW_HOME, or absolute).", ) - project_name: str = Field(default="deerflow", description="Project namespace for projects// pages.") + project_name: str = Field(default="zncm", description="Project namespace for projects// pages.") auto_ingest_enabled: bool = Field(default=False, description="Auto-sediment conversations in the background (phase 3).") auto_ingest_min_chars: int = Field(default=300, description="Minimum characters before auto-ingest considers a turn.") llm_extract_enabled: bool = Field(default=True, description="Distill notes with an LLM (wiki-capture style) instead of the rule-based fallback.") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/extractor.py b/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/extractor.py index cd044a18..a9dbbe63 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/extractor.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/extractor.py @@ -104,26 +104,33 @@ def _summary(turns: list[dict[str, str]], *, limit: int = 280) -> str: return "" -def related_links(project_name: str | None) -> list[str]: - """Structural wikilinks every note carries (wiki-capture requirement). +def related_links(project_name: str | None) -> list[str]: # noqa: ARG001 + """Structural wikilinks every note used to carry. - These point at vault meta/hub pages (``index`` / ``_meta/taxonomy`` / the - project hub) that always exist, so they resolve to navigable pages. Content- - specific "related notes" are injected separately by the service layer (which - has DB access) on top of these. + Product decision: notes no longer auto-link the vault meta/hub pages + (``index`` / ``_meta/taxonomy`` / the project hub) — they were noise in every + note's ``## Related``. Only real, content-specific related notes (injected by + the service layer, which has DB access) are linked now. Kept as a function + (returns empty) so existing callers and the wiki-capture contract stay intact. """ - from deerflow.knowledge.markdown_vault import slugify - - links = ["[[index]]", "[[_meta/taxonomy]]"] - if project_name: - links.append(f"[[projects/{slugify(project_name)}/synthesis/product-knowledge-base]]") - return links + return [] # Backwards-compatible internal alias. _related_links = related_links +def _related_section_md(related_lines: list[str], source_lines: list[str]) -> str: + """Render the ``## Related`` section, or ``""`` when there is nothing to show.""" + if not related_lines and not source_lines: + return "" + inner = "\n".join(related_lines) + if source_lines: + sources_block = "### Sources\n\n" + "\n".join(source_lines) + inner = (inner + "\n\n" + sources_block) if inner else sources_block + return f"## Related\n\n{inner}\n" + + def _build_body( *, title: str, @@ -155,12 +162,9 @@ def _build_body( implications = "- 后续可在相关问答中参考本页知识。\n- 如内容过期或冲突,请编辑或归档。" - related = "\n".join(f"- {link}" for link in _related_links(project_name)) - if sources: - related += "\n\n### Sources\n\n" - for s in sources[:20]: - label = s.title or s.url or "来源" - related += f"- {label}" + (f" — {s.url}" if s.url else "") + "\n" + related_lines = [f"- {link}" for link in _related_links(project_name)] + source_lines = [f"- {s.title or s.url or '来源'}" + (f" — {s.url}" if s.url else "") for s in sources[:20]] + related_section = _related_section_md(related_lines, source_lines) return ( f"# {title}\n\n" @@ -168,7 +172,7 @@ def _build_body( f"## Finding / Decision\n\n{finding}\n\n" f"## Reasoning\n\n{reasoning}\n\n" f"## Implications\n\n{implications}\n\n" - f"## Related\n\n{related}\n" + f"{related_section}" ) @@ -180,7 +184,7 @@ def extract_thread( mode: str = "summary", title: str | None = None, tags: list[str] | None = None, - project_name: str | None = "deerflow", + project_name: str | None = "zncm", sources: list[SourceDraft] | None = None, max_messages: int = 80, ) -> NoteDraft | None: @@ -193,7 +197,7 @@ def extract_thread( sources = sources or [] body = _build_body(title=final_title, turns=turns, mode=mode, project_name=project_name, sources=sources) base_tags = list(tags or []) - for default in (project_name or "deerflow", "knowledge-base"): + for default in (project_name or "zncm", "knowledge-base"): if default and default not in base_tags: base_tags.append(default) return NoteDraft( @@ -216,7 +220,7 @@ def extract_search( sources: list[SourceDraft], *, thread_id: str | None = None, - project_name: str | None = "deerflow", + project_name: str | None = "zncm", title: str | None = None, tags: list[str] | None = None, ) -> NoteDraft: @@ -240,7 +244,7 @@ def extract_search( *[f"- {link}" for link in _related_links(project_name)], ] base_tags = list(tags or []) - for default in (project_name or "deerflow", "knowledge-base", "reference"): + for default in (project_name or "zncm", "knowledge-base", "reference"): if default and default not in base_tags: base_tags.append(default) return NoteDraft( diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/llm_extractor.py b/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/llm_extractor.py index e869aebc..75688a6d 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/llm_extractor.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/llm_extractor.py @@ -120,12 +120,9 @@ def _render_body(data: dict[str, Any], title: str, related: list[str], sources: reasoning = str(data.get("reasoning") or "").strip() or "(未提供推理说明)" implications = _bullets(data.get("implications")) or "- 如内容过期或冲突,请编辑或归档。" - related_block = "\n".join(f"- {link}" for link in related) - if sources: - related_block += "\n\n### Sources\n\n" - for s in sources[:20]: - label = s.title or s.url or "来源" - related_block += f"- {label}" + (f" — {s.url}" if s.url else "") + "\n" + related_lines = [f"- {link}" for link in related] + source_lines = [f"- {s.title or s.url or '来源'}" + (f" — {s.url}" if s.url else "") for s in sources[:20]] + related_section = extractor._related_section_md(related_lines, source_lines) return ( f"# {title}\n\n" @@ -133,7 +130,7 @@ def _render_body(data: dict[str, Any], title: str, related: list[str], sources: f"## Finding / Decision\n\n{findings}\n\n" f"## Reasoning\n\n{reasoning}\n\n" f"## Implications\n\n{implications}\n\n" - f"## Related\n\n{related_block}\n" + f"{related_section}" ) @@ -178,7 +175,7 @@ def _build_note_draft( base_tags = list(tags or []) llm_tags = note_data.get("tags") if isinstance(note_data.get("tags"), list) else [] - for tag in [*llm_tags, project_name or "deerflow", "knowledge-base"]: + for tag in [*llm_tags, project_name or "zncm", "knowledge-base"]: tag = str(tag).strip() if tag and tag not in base_tags: base_tags.append(tag) @@ -216,7 +213,7 @@ async def extract_thread_llm_multi( mode: str = "summary", title: str | None = None, tags: list[str] | None = None, - project_name: str | None = "deerflow", + project_name: str | None = "zncm", sources: list[SourceDraft] | None = None, max_messages: int = 80, model_name: str | None = None, @@ -295,7 +292,7 @@ async def extract_thread_llm( mode: str = "summary", title: str | None = None, tags: list[str] | None = None, - project_name: str | None = "deerflow", + project_name: str | None = "zncm", sources: list[SourceDraft] | None = None, max_messages: int = 80, model_name: str | None = None, diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/markdown_vault.py b/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/markdown_vault.py index 2bfde02b..d36e028d 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/markdown_vault.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/markdown_vault.py @@ -116,21 +116,6 @@ class MarkdownVault: base = self.root / "projects" / slugify(project_name) for d in ("concepts", "references", "synthesis", "journal"): (base / d).mkdir(parents=True, exist_ok=True) - # Project hub page — the structural target every note links to under - # ``## Related`` (``[[projects//synthesis/product-knowledge-base]]``). - # Materialize it so that wikilink is always navigable, not a dangling - # placeholder. - hub = base / "synthesis" / "product-knowledge-base.md" - if not hub.exists(): - hub.write_text( - f"---\ntitle: 产品知识库总览\ncategory: synthesis\n---\n\n" - f"# 产品知识库总览\n\n" - f"本页是 **{project_name}** 项目知识库的入口枢纽(hub)。每条知识都会在 `## Related` 中链接回这里。\n\n" - "- [[index]] — 全部页面索引\n" - "- [[_meta/taxonomy]] — 分类体系\n\n" - "## 主题\n\n_随着知识沉淀,这里会逐步链接到各主题页面。_\n", - encoding="utf-8", - ) # Base vault files. index = self.root / "index.md" if not index.exists(): diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/obsidian_adapter.py b/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/obsidian_adapter.py index 0681b8f9..fef599bc 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/obsidian_adapter.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/obsidian_adapter.py @@ -27,7 +27,7 @@ _HOT_MAX = 20 class ObsidianWikiAdapter: """Read/write an obsidian-wiki compatible vault.""" - def __init__(self, vault_root: Path | str, *, project_name: str = "deerflow", export_html: bool = True, export_json: bool = True) -> None: + def __init__(self, vault_root: Path | str, *, project_name: str = "zncm", export_html: bool = True, export_json: bool = True) -> None: self.vault = MarkdownVault(vault_root) self.root = self.vault.root self.manifest = Manifest(self.root) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/service.py b/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/service.py index 29542e84..fc342be2 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/service.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/service.py @@ -40,11 +40,11 @@ def _resolve_vault_dir(vault_dir: str) -> Path: def _inject_related_notes(body: str, links: list[str]) -> str: - """Insert real related-note ``[[wikilinks]]`` at the top of the Related block. + """Insert real related-note ``[[wikilinks]]`` into the Related block. - Keeps the structural meta-links (index / taxonomy / hub) that the extractor - already rendered, but surfaces the content-specific related notes first. When - there is no ``## Related`` section, one is appended. + Notes carry only content-specific related notes now (the structural meta-links + were dropped), so this is the sole writer of ``## Related`` links. When the + body has no ``## Related`` section yet, one is appended. """ if not links: return body @@ -107,7 +107,7 @@ class KnowledgeService: # tags (the per-note project/knowledge-base defaults are dropped, else # every note would match every other). CJK runs tokenize as a single # token, so shared distinctive tags are what make cross-topic matches work. - default_tags = {t.lower() for t in (self.project_name or "", "knowledge-base", "deerflow") if t} + default_tags = {t.lower() for t in (self.project_name or "", "knowledge-base", "zncm") if t} distinctive = [t for t in (draft.tags or []) if t and t.lower() not in default_tags] query = " ".join(p for p in [draft.title or "", draft.summary or "", *distinctive] if p).strip() if not query: