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]>
148 lines
4.3 KiB
Python
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())
|