okf-kit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- okf_kit/__init__.py +3 -0
- okf_kit/bundle_nav.py +97 -0
- okf_kit/chat/__init__.py +1 -0
- okf_kit/chat/agent.py +92 -0
- okf_kit/chat/history.py +51 -0
- okf_kit/chat/providers.py +161 -0
- okf_kit/chat/repl.py +65 -0
- okf_kit/chat/retrieval.py +35 -0
- okf_kit/cli.py +176 -0
- okf_kit/config.py +30 -0
- okf_kit/crawl.py +157 -0
- okf_kit/enrich.py +88 -0
- okf_kit/fetch/__init__.py +1 -0
- okf_kit/fetch/browser.py +64 -0
- okf_kit/fetch/http.py +161 -0
- okf_kit/mapper.py +59 -0
- okf_kit/mcp.py +96 -0
- okf_kit/model.py +54 -0
- okf_kit/okf.py +132 -0
- okf_kit/registry.py +137 -0
- okf_kit/sync.py +148 -0
- okf_kit/visualize.py +145 -0
- okf_kit/writer.py +129 -0
- okf_kit-0.1.0.dist-info/METADATA +205 -0
- okf_kit-0.1.0.dist-info/RECORD +29 -0
- okf_kit-0.1.0.dist-info/WHEEL +5 -0
- okf_kit-0.1.0.dist-info/entry_points.txt +2 -0
- okf_kit-0.1.0.dist-info/licenses/LICENSE +202 -0
- okf_kit-0.1.0.dist-info/top_level.txt +1 -0
okf_kit/__init__.py
ADDED
okf_kit/bundle_nav.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Bundle navigation primitives shared by chat and the MCP server.
|
|
2
|
+
|
|
3
|
+
These are the tools an agent uses to walk an OKF bundle — the same
|
|
4
|
+
list_directory / read_concept pair proven to let independent agents navigate
|
|
5
|
+
calknowledge bundles, plus a no-index keyword search. All are
|
|
6
|
+
path-traversal-guarded.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .config import STATE_DIRNAME
|
|
15
|
+
from .okf import RESERVED
|
|
16
|
+
|
|
17
|
+
MAX_FILE_CHARS = 12000
|
|
18
|
+
_TOKEN = re.compile(r"[a-z0-9]{2,}")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _safe(bundle_dir: Path, path: str) -> Path | None:
|
|
22
|
+
target = (bundle_dir / path.strip().lstrip("/")).resolve()
|
|
23
|
+
root = bundle_dir.resolve()
|
|
24
|
+
if target == root or root in target.parents:
|
|
25
|
+
return target
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def list_directory(bundle_dir, path: str = "/") -> str:
|
|
30
|
+
bundle_dir = Path(bundle_dir)
|
|
31
|
+
target = _safe(bundle_dir, path)
|
|
32
|
+
if not target or not target.is_dir():
|
|
33
|
+
return f"error: {path} is not a directory in this bundle. Try /index.md."
|
|
34
|
+
entries = sorted(
|
|
35
|
+
("dir: " if p.is_dir() else "file: ") + p.name
|
|
36
|
+
for p in target.iterdir()
|
|
37
|
+
if p.name != STATE_DIRNAME
|
|
38
|
+
)
|
|
39
|
+
return "\n".join(entries) or "(empty)"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def read_concept(bundle_dir, path: str) -> str:
|
|
43
|
+
bundle_dir = Path(bundle_dir)
|
|
44
|
+
target = _safe(bundle_dir, path)
|
|
45
|
+
if not target or not target.is_file():
|
|
46
|
+
return f"error: {path} is not a file in this bundle. Read /index.md to see what exists."
|
|
47
|
+
return target.read_text(encoding="utf8")[:MAX_FILE_CHARS]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _concept_files(bundle_dir):
|
|
51
|
+
for md in (Path(bundle_dir) / "pages").rglob("*.md"):
|
|
52
|
+
if md.name not in RESERVED:
|
|
53
|
+
yield md
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _split_frontmatter(text: str) -> tuple[str | None, str]:
|
|
57
|
+
"""Return (title, body-without-frontmatter)."""
|
|
58
|
+
title = None
|
|
59
|
+
body = text
|
|
60
|
+
if text.startswith("---\n"):
|
|
61
|
+
end = text.find("\n---\n", 4)
|
|
62
|
+
if end != -1:
|
|
63
|
+
fm = text[4:end]
|
|
64
|
+
m = re.search(r"^title:\s*(.+)$", fm, re.MULTILINE)
|
|
65
|
+
if m:
|
|
66
|
+
title = m.group(1).strip()
|
|
67
|
+
body = text[end + 5 :]
|
|
68
|
+
return title, body
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def search_bundle(bundle_dir, query: str, limit: int = 5) -> list[dict]:
|
|
72
|
+
"""Keyword search over concept files (title-boosted). No index needed."""
|
|
73
|
+
bundle_dir = Path(bundle_dir)
|
|
74
|
+
terms = set(_TOKEN.findall(query.lower()))
|
|
75
|
+
if not terms:
|
|
76
|
+
return []
|
|
77
|
+
results = []
|
|
78
|
+
for md in _concept_files(bundle_dir):
|
|
79
|
+
title, body = _split_frontmatter(md.read_text(encoding="utf8"))
|
|
80
|
+
body_l = body.lower()
|
|
81
|
+
title_l = (title or "").lower()
|
|
82
|
+
score = sum(body_l.count(t) + 3 * title_l.count(t) for t in terms)
|
|
83
|
+
if score:
|
|
84
|
+
rel = "/" + str(md.relative_to(bundle_dir))
|
|
85
|
+
results.append({"path": rel, "title": title, "score": score,
|
|
86
|
+
"snippet": _snippet(body, terms)})
|
|
87
|
+
results.sort(key=lambda r: -r["score"])
|
|
88
|
+
return results[:limit]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _snippet(text: str, terms: set[str], width: int = 220) -> str:
|
|
92
|
+
lower = text.lower()
|
|
93
|
+
pos = min((lower.find(t) for t in terms if lower.find(t) >= 0), default=-1)
|
|
94
|
+
if pos < 0:
|
|
95
|
+
return ""
|
|
96
|
+
start = max(0, pos - width // 3)
|
|
97
|
+
return re.sub(r"\s+", " ", text[start : start + width]).strip()
|
okf_kit/chat/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Chat with a bundle (M3): providers, REPL, local history."""
|
okf_kit/chat/agent.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""The bundle-navigation agent: answer a question by walking the OKF bundle.
|
|
2
|
+
|
|
3
|
+
Ported from calknowledge's OKF-mode agent (verified to navigate large bundles
|
|
4
|
+
correctly with independent LLMs): seed the root index, descend directory
|
|
5
|
+
indexes to the most specific concept, answer only from what was read.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from ..bundle_nav import list_directory, read_concept
|
|
13
|
+
|
|
14
|
+
MAX_STEPS = 16
|
|
15
|
+
|
|
16
|
+
SYSTEM = """\
|
|
17
|
+
You are a knowledge agent answering questions from an OKF bundle: a directory
|
|
18
|
+
of markdown concept files with YAML frontmatter. Every directory has an
|
|
19
|
+
index.md listing its contents.
|
|
20
|
+
|
|
21
|
+
Navigate with the tools:
|
|
22
|
+
- list_directory(path): list a directory (e.g. "/", "/pages", "/pages/docs")
|
|
23
|
+
- read_concept(path): read a file (e.g. "/pages/docs/intro.md")
|
|
24
|
+
|
|
25
|
+
Strategy: the root index is provided below. Descend through directory indexes
|
|
26
|
+
to the MOST SPECIFIC concept for the question — don't stop at a general page
|
|
27
|
+
when a dedicated one exists. If a tool errors, read the nearest parent
|
|
28
|
+
index.md. Answer ONLY from concept files you have read; if the bundle doesn't
|
|
29
|
+
contain the answer, say so. Cite the bundle paths you used."""
|
|
30
|
+
|
|
31
|
+
TOOLS = [
|
|
32
|
+
{
|
|
33
|
+
"type": "function",
|
|
34
|
+
"function": {
|
|
35
|
+
"name": "list_directory",
|
|
36
|
+
"description": "List entries in a directory of the OKF bundle.",
|
|
37
|
+
"parameters": {
|
|
38
|
+
"type": "object",
|
|
39
|
+
"properties": {"path": {"type": "string", "description": "Bundle path, e.g. /pages"}},
|
|
40
|
+
"required": ["path"],
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"type": "function",
|
|
46
|
+
"function": {
|
|
47
|
+
"name": "read_concept",
|
|
48
|
+
"description": "Read a markdown concept file from the OKF bundle.",
|
|
49
|
+
"parameters": {
|
|
50
|
+
"type": "object",
|
|
51
|
+
"properties": {"path": {"type": "string", "description": "Bundle path, e.g. /index.md"}},
|
|
52
|
+
"required": ["path"],
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def ask(bundle_dir, question: str, provider, *, max_steps: int = MAX_STEPS) -> dict:
|
|
60
|
+
"""Run the navigation loop; returns {answer, steps, sources}."""
|
|
61
|
+
bundle_dir = Path(bundle_dir)
|
|
62
|
+
root_index = read_concept(bundle_dir, "/index.md")
|
|
63
|
+
messages = [
|
|
64
|
+
{"role": "system", "content": SYSTEM},
|
|
65
|
+
{"role": "user", "content": f"Question: {question}\n\nBundle root index (/index.md):\n{root_index}"},
|
|
66
|
+
]
|
|
67
|
+
steps: list[dict] = []
|
|
68
|
+
read_paths: list[str] = []
|
|
69
|
+
answer = None
|
|
70
|
+
|
|
71
|
+
for _ in range(max_steps):
|
|
72
|
+
turn = provider.complete(messages, TOOLS)
|
|
73
|
+
messages.append(provider.assistant_message(turn))
|
|
74
|
+
if not turn.tool_calls:
|
|
75
|
+
answer = turn.text
|
|
76
|
+
break
|
|
77
|
+
for call in turn.tool_calls:
|
|
78
|
+
path = call.arguments.get("path", "/")
|
|
79
|
+
if call.name == "read_concept":
|
|
80
|
+
result = read_concept(bundle_dir, path)
|
|
81
|
+
if not result.startswith("error:") and path not in read_paths:
|
|
82
|
+
read_paths.append(path)
|
|
83
|
+
else:
|
|
84
|
+
result = list_directory(bundle_dir, path)
|
|
85
|
+
steps.append({"tool": call.name, "path": path})
|
|
86
|
+
messages.append(provider.tool_result_message(call, result))
|
|
87
|
+
|
|
88
|
+
if answer is None:
|
|
89
|
+
messages.append({"role": "user", "content": "Stop exploring and answer from what you've read."})
|
|
90
|
+
answer = provider.complete(messages, []).text
|
|
91
|
+
|
|
92
|
+
return {"answer": answer or "", "steps": steps, "sources": read_paths}
|
okf_kit/chat/history.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Local chat history: one JSONL session file per conversation.
|
|
2
|
+
|
|
3
|
+
Stored under ~/.okf/chats/<bundle>/<timestamp>.jsonl — nothing leaves the
|
|
4
|
+
machine except the provider call itself.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
from ..config import chats_dir
|
|
12
|
+
from ..model import utcnow_iso
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class History:
|
|
16
|
+
def __init__(self, bundle_name: str, session: str | None = None):
|
|
17
|
+
self.dir = chats_dir() / bundle_name
|
|
18
|
+
self.dir.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
if session:
|
|
20
|
+
self.path = self.dir / f"{session}.jsonl"
|
|
21
|
+
else:
|
|
22
|
+
self.path = self.dir / f"{utcnow_iso().replace(':', '-')}.jsonl"
|
|
23
|
+
|
|
24
|
+
def append(self, role: str, content: str, meta: dict | None = None) -> None:
|
|
25
|
+
rec = {"ts": utcnow_iso(), "role": role, "content": content}
|
|
26
|
+
if meta:
|
|
27
|
+
rec["meta"] = meta
|
|
28
|
+
with self.path.open("a", encoding="utf8") as fh:
|
|
29
|
+
fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
|
30
|
+
|
|
31
|
+
def load(self) -> list[dict]:
|
|
32
|
+
if not self.path.exists():
|
|
33
|
+
return []
|
|
34
|
+
return [json.loads(line) for line in self.path.read_text(encoding="utf8").splitlines() if line.strip()]
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def latest(cls, bundle_name: str) -> "History | None":
|
|
38
|
+
d = chats_dir() / bundle_name
|
|
39
|
+
sessions = sorted(d.glob("*.jsonl")) if d.exists() else []
|
|
40
|
+
if not sessions:
|
|
41
|
+
return None
|
|
42
|
+
return cls(bundle_name, session=sessions[-1].stem)
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def list_sessions(cls, bundle_name: str) -> list[tuple[str, int]]:
|
|
46
|
+
d = chats_dir() / bundle_name
|
|
47
|
+
out = []
|
|
48
|
+
for f in sorted(d.glob("*.jsonl")) if d.exists() else []:
|
|
49
|
+
turns = sum(1 for line in f.read_text(encoding="utf8").splitlines() if '"role": "user"' in line)
|
|
50
|
+
out.append((f.stem, turns))
|
|
51
|
+
return out
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""LLM provider abstraction for chat — deliberately tiny (no LangChain).
|
|
2
|
+
|
|
3
|
+
One protocol, two implementations:
|
|
4
|
+
OpenAICompatProvider — any OpenAI-compatible endpoint (OpenAI, Ollama,
|
|
5
|
+
vLLM, LM Studio, OpenRouter). okf-kit[chat].
|
|
6
|
+
AnthropicProvider — Claude with tool use. okf-kit[anthropic].
|
|
7
|
+
|
|
8
|
+
`make_provider` returns None when no provider/key is available, so the caller
|
|
9
|
+
falls back to zero-key retrieval.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
|
|
17
|
+
# Preset endpoints for OpenAI-compatible servers.
|
|
18
|
+
_PRESETS = {
|
|
19
|
+
"openai": {"base_url": None, "key_env": "OPENAI_API_KEY", "default_model": "gpt-4o-mini"},
|
|
20
|
+
"ollama": {"base_url": "http://localhost:11434/v1", "key_env": None, "default_model": "llama3.1"},
|
|
21
|
+
"openrouter": {"base_url": "https://openrouter.ai/api/v1", "key_env": "OPENROUTER_API_KEY",
|
|
22
|
+
"default_model": "openai/gpt-4o-mini"},
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ToolCall:
|
|
28
|
+
id: str
|
|
29
|
+
name: str
|
|
30
|
+
arguments: dict
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Turn:
|
|
35
|
+
text: str | None = None
|
|
36
|
+
tool_calls: list[ToolCall] = field(default_factory=list)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class OpenAICompatProvider:
|
|
40
|
+
def __init__(self, model: str, base_url: str | None, api_key: str):
|
|
41
|
+
try:
|
|
42
|
+
from openai import OpenAI
|
|
43
|
+
except ImportError as exc: # noqa: TRY003
|
|
44
|
+
raise SystemExit("Chat needs the chat extra: pip install 'okf-kit[chat]'") from exc
|
|
45
|
+
self.model = model
|
|
46
|
+
self._client = OpenAI(base_url=base_url, api_key=api_key)
|
|
47
|
+
|
|
48
|
+
def complete(self, messages: list[dict], tools: list[dict]) -> Turn:
|
|
49
|
+
resp = self._client.chat.completions.create(
|
|
50
|
+
model=self.model, messages=messages, tools=tools or None
|
|
51
|
+
)
|
|
52
|
+
msg = resp.choices[0].message
|
|
53
|
+
calls = [
|
|
54
|
+
ToolCall(id=tc.id, name=tc.function.name, arguments=_loads(tc.function.arguments))
|
|
55
|
+
for tc in (msg.tool_calls or [])
|
|
56
|
+
]
|
|
57
|
+
return Turn(text=msg.content, tool_calls=calls)
|
|
58
|
+
|
|
59
|
+
def assistant_message(self, turn: Turn) -> dict:
|
|
60
|
+
m: dict = {"role": "assistant", "content": turn.text or ""}
|
|
61
|
+
if turn.tool_calls:
|
|
62
|
+
m["tool_calls"] = [
|
|
63
|
+
{"id": c.id, "type": "function",
|
|
64
|
+
"function": {"name": c.name, "arguments": _dumps(c.arguments)}}
|
|
65
|
+
for c in turn.tool_calls
|
|
66
|
+
]
|
|
67
|
+
return m
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def tool_result_message(call: ToolCall, content: str) -> dict:
|
|
71
|
+
return {"role": "tool", "tool_call_id": call.id, "content": content}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class AnthropicProvider:
|
|
75
|
+
def __init__(self, model: str, api_key: str):
|
|
76
|
+
try:
|
|
77
|
+
from anthropic import Anthropic
|
|
78
|
+
except ImportError as exc: # noqa: TRY003
|
|
79
|
+
raise SystemExit("Claude chat needs: pip install 'okf-kit[anthropic]'") from exc
|
|
80
|
+
self.model = model
|
|
81
|
+
self._client = Anthropic(api_key=api_key)
|
|
82
|
+
self._system = None
|
|
83
|
+
|
|
84
|
+
def complete(self, messages: list[dict], tools: list[dict]) -> Turn:
|
|
85
|
+
system, msgs = _split_system(messages)
|
|
86
|
+
atools = [
|
|
87
|
+
{"name": t["function"]["name"], "description": t["function"]["description"],
|
|
88
|
+
"input_schema": t["function"]["parameters"]}
|
|
89
|
+
for t in tools
|
|
90
|
+
]
|
|
91
|
+
resp = self._client.messages.create(
|
|
92
|
+
model=self.model, max_tokens=2048, system=system or "", messages=msgs, tools=atools or []
|
|
93
|
+
)
|
|
94
|
+
text_parts, calls = [], []
|
|
95
|
+
for block in resp.content:
|
|
96
|
+
if block.type == "text":
|
|
97
|
+
text_parts.append(block.text)
|
|
98
|
+
elif block.type == "tool_use":
|
|
99
|
+
calls.append(ToolCall(id=block.id, name=block.name, arguments=dict(block.input)))
|
|
100
|
+
return Turn(text="".join(text_parts) or None, tool_calls=calls)
|
|
101
|
+
|
|
102
|
+
def assistant_message(self, turn: Turn) -> dict:
|
|
103
|
+
content: list[dict] = []
|
|
104
|
+
if turn.text:
|
|
105
|
+
content.append({"type": "text", "text": turn.text})
|
|
106
|
+
for c in turn.tool_calls:
|
|
107
|
+
content.append({"type": "tool_use", "id": c.id, "name": c.name, "input": c.arguments})
|
|
108
|
+
return {"role": "assistant", "content": content}
|
|
109
|
+
|
|
110
|
+
@staticmethod
|
|
111
|
+
def tool_result_message(call: ToolCall, content: str) -> dict:
|
|
112
|
+
return {"role": "user", "content": [
|
|
113
|
+
{"type": "tool_result", "tool_use_id": call.id, "content": content}]}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def make_provider(provider: str | None, model: str | None, base_url: str | None):
|
|
117
|
+
"""Return a provider instance, or None to use the zero-key retrieval fallback."""
|
|
118
|
+
if provider in (None, "none"):
|
|
119
|
+
return None
|
|
120
|
+
if provider == "anthropic":
|
|
121
|
+
key = os.environ.get("ANTHROPIC_API_KEY")
|
|
122
|
+
if not key:
|
|
123
|
+
return None
|
|
124
|
+
return AnthropicProvider(model or "claude-sonnet-5", key)
|
|
125
|
+
if provider in _PRESETS or provider == "custom":
|
|
126
|
+
preset = _PRESETS.get(provider, {})
|
|
127
|
+
url = base_url or preset.get("base_url")
|
|
128
|
+
key_env = preset.get("key_env")
|
|
129
|
+
key = os.environ.get(key_env) if key_env else None
|
|
130
|
+
if provider == "ollama":
|
|
131
|
+
key = key or "ollama" # Ollama ignores the key but the SDK requires one
|
|
132
|
+
if not key:
|
|
133
|
+
return None
|
|
134
|
+
return OpenAICompatProvider(model or preset.get("default_model", "gpt-4o-mini"), url, key)
|
|
135
|
+
raise SystemExit(f"Unknown provider '{provider}'. Use openai/ollama/openrouter/anthropic/custom.")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _loads(s):
|
|
139
|
+
import json
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
return json.loads(s or "{}")
|
|
143
|
+
except json.JSONDecodeError:
|
|
144
|
+
return {}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _dumps(d):
|
|
148
|
+
import json
|
|
149
|
+
|
|
150
|
+
return json.dumps(d)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _split_system(messages):
|
|
154
|
+
system = None
|
|
155
|
+
msgs = []
|
|
156
|
+
for m in messages:
|
|
157
|
+
if m["role"] == "system":
|
|
158
|
+
system = m["content"]
|
|
159
|
+
else:
|
|
160
|
+
msgs.append(m)
|
|
161
|
+
return system, msgs
|
okf_kit/chat/repl.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Terminal chat loop for `okf chat`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ..registry import resolve_bundle
|
|
8
|
+
from . import agent, retrieval
|
|
9
|
+
from .history import History
|
|
10
|
+
from .providers import make_provider
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run_chat(
|
|
14
|
+
name_or_dir: str,
|
|
15
|
+
*,
|
|
16
|
+
provider: str | None = None,
|
|
17
|
+
model: str | None = None,
|
|
18
|
+
base_url: str | None = None,
|
|
19
|
+
trace: bool = False,
|
|
20
|
+
resume: bool = False,
|
|
21
|
+
show_history: bool = False,
|
|
22
|
+
) -> int:
|
|
23
|
+
bundle_dir = resolve_bundle(name_or_dir)
|
|
24
|
+
bundle_name = Path(bundle_dir).name
|
|
25
|
+
|
|
26
|
+
if show_history:
|
|
27
|
+
sessions = History.list_sessions(bundle_name)
|
|
28
|
+
if not sessions:
|
|
29
|
+
print("No saved chats for this bundle.")
|
|
30
|
+
for name, turns in sessions:
|
|
31
|
+
print(f"{name} ({turns} turns)")
|
|
32
|
+
return 0
|
|
33
|
+
|
|
34
|
+
prov = make_provider(provider, model, base_url)
|
|
35
|
+
history = History.latest(bundle_name) if resume else History(bundle_name)
|
|
36
|
+
if resume and history is None:
|
|
37
|
+
history = History(bundle_name)
|
|
38
|
+
|
|
39
|
+
if prov is None:
|
|
40
|
+
print("No LLM provider configured — retrieval-only mode.")
|
|
41
|
+
print("For a synthesized answer: --provider ollama (offline) or --provider openai\n")
|
|
42
|
+
else:
|
|
43
|
+
print(f"Chatting with '{bundle_name}' via {provider or 'default'} — Ctrl-D to exit.\n")
|
|
44
|
+
|
|
45
|
+
while True:
|
|
46
|
+
try:
|
|
47
|
+
question = input("you> ").strip()
|
|
48
|
+
except (EOFError, KeyboardInterrupt):
|
|
49
|
+
print()
|
|
50
|
+
return 0
|
|
51
|
+
if not question:
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
history.append("user", question)
|
|
55
|
+
result = retrieval.answer(bundle_dir, question) if prov is None else agent.ask(
|
|
56
|
+
bundle_dir, question, prov
|
|
57
|
+
)
|
|
58
|
+
print(f"\n{result['answer']}\n")
|
|
59
|
+
if trace and result["steps"]:
|
|
60
|
+
print(" trace: " + " → ".join(f"{s['tool']} {s['path']}" for s in result["steps"]))
|
|
61
|
+
if result["sources"]:
|
|
62
|
+
print(" sources: " + ", ".join(result["sources"]))
|
|
63
|
+
print()
|
|
64
|
+
history.append("assistant", result["answer"],
|
|
65
|
+
meta={"steps": result["steps"], "sources": result["sources"]})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Zero-key fallback: answer from keyword retrieval with citations.
|
|
2
|
+
|
|
3
|
+
When no LLM provider is configured, `okf chat` still does something useful —
|
|
4
|
+
it returns the most relevant concepts with their matching passages quoted and
|
|
5
|
+
cited, clearly labeled as retrieval-only. (Adopted from the deterministic
|
|
6
|
+
`ask` in bushans/okfgen; see docs/ECOSYSTEM.md.)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from ..bundle_nav import search_bundle
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def answer(bundle_dir, question: str, *, limit: int = 3) -> dict:
|
|
17
|
+
bundle_dir = Path(bundle_dir)
|
|
18
|
+
hits = search_bundle(bundle_dir, question, limit=limit)
|
|
19
|
+
if not hits:
|
|
20
|
+
return {
|
|
21
|
+
"answer": "No matching content found in this bundle (retrieval-only, no LLM configured).",
|
|
22
|
+
"steps": [{"tool": "search_bundle", "path": question}],
|
|
23
|
+
"sources": [],
|
|
24
|
+
}
|
|
25
|
+
lines = ["Retrieval-only answer (no LLM configured) — most relevant concepts:\n"]
|
|
26
|
+
for i, h in enumerate(hits, 1):
|
|
27
|
+
title = h["title"] or h["path"]
|
|
28
|
+
lines.append(f"{i}. {title} [{h['path']}]")
|
|
29
|
+
lines.append(f" {h['snippet']}\n")
|
|
30
|
+
lines.append("Configure a provider for a synthesized answer: okf chat <bundle> --provider ollama")
|
|
31
|
+
return {
|
|
32
|
+
"answer": "\n".join(lines),
|
|
33
|
+
"steps": [{"tool": "search_bundle", "path": question}],
|
|
34
|
+
"sources": [h["path"] for h in hits],
|
|
35
|
+
}
|