feat(knowledge): 项目名改 zncm + 去除元链接噪音 + 创建人显示人名
- 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) <noreply@anthropic.com>
This commit is contained in:
1 parent
df3fac1032
commit
e7ff07debd
11 files changed
+83
-61
No files matched your search
@@ -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[];
|
||||
|
||||
@@ -65,6 +65,13 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
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<T>(value: T, delay = 350): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
useEffect(() => {
|
||||
@@ -593,7 +600,8 @@ function NoteDetail({ noteId }: { noteId: string }) {
|
||||
) : null}
|
||||
|
||||
<div className="text-muted-foreground mt-6 border-t pt-3 text-xs">
|
||||
创建人 {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", " ")}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -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/<project>/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):**
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 规范提炼陈述式知识(而非堆砌对话原文)。
|
||||
|
||||
+1
-1
@@ -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/<name>/ pages.")
|
||||
project_name: str = Field(default="zncm", description="Project namespace for projects/<name>/ 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.")
|
||||
|
||||
@@ -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(
|
||||
|
||||
+7
-10
@@ -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,
|
||||
|
||||
-15
@@ -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/<project>/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():
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user