Files
brunoandClaude Opus 4.7 e80cab384d feat(knowledge): add context_pack, validate_citations, query_knowledge (W3+W4)
Implements the per-task knowledge ingest + query loop described in
ljwx-workflow/docs/knowledge-as-code-workflow.md (W3, W4). Builds the
dev-agent side of the knowledge plane on top of the existing
business-repo -> central ingest -> Dify pipeline.

- dify_client.retrieve(): new POST /datasets/{id}/retrieve binding so
  context_pack can query Dify by text. Returns typed DifyRetrievalHit
  list with chunk_id, document_id, content, score, position.
- context_pack.py: composer. Pipeline = module-map resolution (changed
  files -> modules -> spec/ADR paths) + optional Dify retrieval (when
  KNOWLEDGE_DIFY_BASE_URL + KNOWLEDGE_DIFY_API_KEY env set) + local
  keyword fallback over publish-records content_ref markdown. Outputs
  context-pack.md (for agent consumption) and context-pack.json
  (manifest with chunk_id, source_ref, tier, text_sha256 per section).
  Token-bounded composition with tier-aware ranking; pack header records
  dify_query_at + token_estimate. Gracefully degrades when no Dify
  creds, no module-map, or no records.
- validate_citations.py: extracts and verifies citations in agent
  output. Grammar = [kg:repo:doc_id:chunk_id], [kg:repo:source_path],
  path:Lx-Ly. Produces {verified, tier, severity_cap} per citation;
  unverified caps at info, tier=legacy/proposed/unknown cap at warn,
  tier=ssot/reference allows block. file_line verification is
  bounds-only until W3.2 adds per-chunk line ranges to artifacts.
- query_knowledge.py: thin user-facing query CLI over the same store,
  for local exploration and dogfooding.

Smoke verified end-to-end against ljwx-platform's 4 published records:
query CLI returns ranked matches; context_pack builds a 4-section pack
(4223 tokens) for a 'review' task; validate_citations correctly
classifies 5 synthetic citations (2 verified + 3 unverified, severity
caps applied per rule).

Known limitations (tracked elsewhere):
- All tiers show 'unknown' until W1 schema additions land in publish
  records (audience/tier/excluded_from_retrieval); context_pack handles
  this without crashing.
- Module-map resolution skipped for repos without
  knowledge.module-map.yaml; falls back to keyword + Dify (W3.5).
- file_line citations only verify file existence + line bounds, not
  line content (waiting on W3.2 chunk line ranges).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-17 10:55:43 +08:00

148 lines
4.3 KiB
Python

#!/usr/bin/env python3
"""Interactive query CLI over the knowledge plane.
Thin wrapper over context_pack so dogfooding catches integration bugs.
Mainly for local exploration: 'show me what the knowledge plane has for
<topic>' without going through the full pack-write-to-disk flow.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
from .context_pack import (
DEFAULT_TOP_K,
PackInputs,
PublishRecordStore,
_build_dify_sections,
_build_keyword_sections,
_cap_by_budget,
_dedupe,
_qualify_repo,
_rank,
_resolve_dify,
_strip_repo_owner,
)
from .common import utc_now
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Query the knowledge plane (Dify when available, local fallback otherwise)"
)
parser.add_argument("--repo", required=True, help="service name or owner__repo")
parser.add_argument("--query", required=True)
parser.add_argument("--top-k", type=int, default=DEFAULT_TOP_K)
parser.add_argument(
"--deploy-root", type=Path, default=Path.cwd(), help="ljwx-deploy root"
)
parser.add_argument("--env", default="dev")
parser.add_argument(
"--skip-dify", action="store_true", help="force local fallback"
)
parser.add_argument(
"--format",
choices=["pretty", "json"],
default="pretty",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
repo_qualifier = _qualify_repo(args.repo)
repo_owner_slash = _strip_repo_owner(repo_qualifier)
inputs = PackInputs(
repo=repo_owner_slash,
mode="implement",
task_text=args.query,
changed_files=(),
repo_root=Path.cwd(),
deploy_root=args.deploy_root,
output_dir=Path("/dev/null"),
env=args.env,
token_budget=20_000,
top_k=args.top_k,
)
store = PublishRecordStore(inputs.deploy_root, inputs.env)
loaded = store.load_for_repo(repo_qualifier)
sections = []
dify_used = False
if not args.skip_dify:
client, dataset_ids = _resolve_dify(inputs.deploy_root)
if client is not None and dataset_ids:
sections = _build_dify_sections(
store=store,
inputs=inputs,
dify=client,
dataset_ids=dataset_ids,
query=args.query,
)
dify_used = bool(sections)
if not sections:
sections = _build_keyword_sections(store, inputs, args.query)
sections = _dedupe(sections)
sections = _rank(sections)
sections = _cap_by_budget(sections, inputs.token_budget)
if args.format == "json":
out = {
"query": args.query,
"repo": repo_owner_slash,
"records_loaded": loaded,
"dify_used": dify_used,
"results": [
{
"title": s.title,
"tier": s.tier,
"source_path": s.source_path,
"source_ref": s.source_ref,
"document_id": s.document_id,
"chunk_id": s.chunk_id,
"origin": s.origin,
"score": s.score,
"snippet": s.text[:280],
}
for s in sections
],
}
print(json.dumps(out, ensure_ascii=False, indent=2))
return 0
print(f"# Query: {args.query}")
print(f"# Repo: {repo_owner_slash} records loaded: {loaded} dify: {dify_used}")
print(f"# Time: {utc_now()}")
print(f"# Results: {len(sections)}\n")
if not sections:
print("(no matches)")
return 0
for index, s in enumerate(sections, start=1):
cite = (
f"[kg:{s.source_repo}:{s.document_id}:{s.chunk_id}]"
if s.chunk_id
else f"[kg:{s.source_repo}:{s.source_path}]"
)
print(f"## {index}. {s.title}")
print(f" tier={s.tier} origin={s.origin} score={s.score:.2f}")
print(f" path: {s.source_path}")
print(f" cite: {cite}")
snippet = s.text.strip().replace("\n", " ")
if len(snippet) > 280:
snippet = snippet[:280] + "…"
print(f" {snippet}")
print()
return 0
if __name__ == "__main__":
sys.exit(main())