ltcai 9.9.2 → 9.9.4
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.
- package/README.md +44 -39
- package/docs/CHANGELOG.md +109 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/SURFACE_PARITY.md +47 -0
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/_kg_common.py +335 -0
- package/lattice_brain/graph/curator.py +141 -0
- package/lattice_brain/graph/discovery_index.py +8 -1
- package/lattice_brain/graph/documents.py +111 -0
- package/lattice_brain/graph/fusion.py +179 -0
- package/lattice_brain/graph/ingest.py +43 -9
- package/lattice_brain/graph/projection.py +382 -37
- package/lattice_brain/graph/retrieval.py +71 -4
- package/lattice_brain/graph/retrieval_policy.py +174 -0
- package/lattice_brain/graph/retrieval_vector.py +122 -2
- package/lattice_brain/ingestion.py +66 -0
- package/lattice_brain/portability.py +41 -12
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/automation_intelligence.py +159 -0
- package/latticeai/api/browser.py +15 -4
- package/latticeai/api/chat.py +33 -2
- package/latticeai/api/chat_agent_http.py +363 -6
- package/latticeai/api/chat_contracts.py +7 -1
- package/latticeai/api/chat_helpers.py +147 -0
- package/latticeai/api/chat_intents.py +217 -10
- package/latticeai/api/chat_stream.py +93 -3
- package/latticeai/api/funnel_metrics.py +31 -0
- package/latticeai/api/knowledge_graph.py +65 -1
- package/latticeai/api/local_files.py +90 -0
- package/latticeai/api/setup.py +134 -1
- package/latticeai/api/tools.py +33 -3
- package/latticeai/app_factory.py +14 -0
- package/latticeai/core/agent.py +355 -18
- package/latticeai/core/agent_eval.py +107 -0
- package/latticeai/core/enterprise.py +5 -0
- package/latticeai/core/file_generation.py +404 -2
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/run_store.py +243 -0
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/models/router.py +25 -12
- package/latticeai/runtime/persistence_runtime.py +19 -1
- package/latticeai/runtime/router_registration.py +8 -1
- package/latticeai/services/app_context.py +3 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/automation_execution.py +266 -0
- package/latticeai/services/automation_intelligence.py +7 -0
- package/latticeai/services/command_center.py +110 -2
- package/latticeai/services/folder_watch.py +396 -0
- package/latticeai/services/funnel_metrics.py +210 -0
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/search_service.py +51 -5
- package/latticeai/setup/demo_corpus.py +98 -0
- package/latticeai/tools/filesystem.py +38 -1
- package/package.json +1 -1
- package/scripts/bench_agent_smoke.py +410 -0
- package/scripts/bench_models.py +234 -5
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/funnel_soft_gate.py +192 -0
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +29 -27
- package/static/app/assets/Act-BCmTU0E2.js +1 -0
- package/static/app/assets/{Brain-BnGbytHa.js → Brain-CTnjox7w.js} +2 -2
- package/static/app/assets/BrainHome-BJ3sFNX0.js +2 -0
- package/static/app/assets/BrainSignals-C52lwZVD.js +1 -0
- package/static/app/assets/{Capture-CAKnLcb0.js → Capture-B6vBhFa3.js} +1 -1
- package/static/app/assets/CommandPalette-90u9FWFH.js +1 -0
- package/static/app/assets/Library-pKCK0_tk.js +1 -0
- package/static/app/assets/{LivingBrain-C3VbogVo.js → LivingBrain-Jlf2wFqI.js} +1 -1
- package/static/app/assets/{ProductFlow-BLau7c7N.js → ProductFlow-yg1fKP1P.js} +1 -1
- package/static/app/assets/ReviewCard-DWvD7n9h.js +3 -0
- package/static/app/assets/{System-DRHc_6Su.js → System-BAEuHqNY.js} +1 -1
- package/static/app/assets/{bot-DZkDdh-A.js → bot-nB_buEZD.js} +1 -1
- package/static/app/assets/circle-pause-DLNw6Ucp.js +1 -0
- package/static/app/assets/circle-play-B-IsFL1y.js +1 -0
- package/static/app/assets/{cpu-DyvsDBbj.js → cpu-CEPBHaBl.js} +1 -1
- package/static/app/assets/{folder-open-CffoIr9t.js → folder-open-hmN0N9cX.js} +1 -1
- package/static/app/assets/{hard-drive-C1oojrHb.js → hard-drive-CBV_B_Yd.js} +1 -1
- package/static/app/assets/index-7FAfYm4v.css +2 -0
- package/static/app/assets/{index-BS_3aN1n.js → index-DrmOCySv.js} +3 -3
- package/static/app/assets/{input-B_U7zvbR.js → input-gtVCg-ll.js} +1 -1
- package/static/app/assets/{navigation-xG-gyjbe.js → navigation-Bot0hvuv.js} +1 -1
- package/static/app/assets/{network-BF13HJJF.js → network-jE42eKfT.js} +1 -1
- package/static/app/assets/{primitives-DsuKh3M8.js → primitives-CX2Komon.js} +1 -1
- package/static/app/assets/shield-alert-BftATuAA.js +1 -0
- package/static/app/assets/{textarea-DsDL4ET8.js → textarea-CiMJfOSI.js} +1 -1
- package/static/app/assets/useFocusTrap-B7RPGfFy.js +1 -0
- package/static/app/assets/utils-SJUNVOj5.js +7 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/Act-Cx_gZNbE.js +0 -1
- package/static/app/assets/BrainHome-CRUMJL93.js +0 -2
- package/static/app/assets/BrainSignals-BP8-Qh2g.js +0 -1
- package/static/app/assets/CommandPalette-BoUTXBfm.js +0 -1
- package/static/app/assets/Library-PcfVUco6.js +0 -1
- package/static/app/assets/ReviewCard-CX5RqRqx.js +0 -1
- package/static/app/assets/download-C7yxYHvz.js +0 -1
- package/static/app/assets/index-DU78Kogz.css +0 -2
- package/static/app/assets/sunrise-CIP2ELJ1.js +0 -1
- package/static/app/assets/utils-Dh-Dwtkb.js +0 -7
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""UX funnel metrics — lightweight runtime counters (backlog #16, review §4.3).
|
|
2
|
+
|
|
3
|
+
Tracks the product's core value funnel with honest, local-only counters:
|
|
4
|
+
|
|
5
|
+
* ``file_requests`` — chat requests recognized as file intents;
|
|
6
|
+
* ``real_file_delivered`` — file intents that produced actual artifacts;
|
|
7
|
+
* ``code_only_responses`` — file intents that finished with only a
|
|
8
|
+
code/prose answer (the failure mode the >95% real-file goal watches);
|
|
9
|
+
* ``agent_runs`` — completed chat agent runs (rate denominator);
|
|
10
|
+
* ``needs_review_runs`` — agent runs that ended in ``NEEDS_REVIEW``;
|
|
11
|
+
* ``ingest_completions`` — successful ingestion pipeline completions;
|
|
12
|
+
* ``recall_successes`` — chat turns whose context was grounded in at
|
|
13
|
+
least one Brain node;
|
|
14
|
+
* ``approval_pauses`` — agent/workflow runs paused for human approval;
|
|
15
|
+
* ``approval_resumes`` — paused runs a human explicitly resumed
|
|
16
|
+
(approved), the ``approval_resume_rate`` numerator.
|
|
17
|
+
|
|
18
|
+
TTFV ("time to first value") derives from two first-occurrence timestamps:
|
|
19
|
+
the first successful ingest and the first grounded recall/answer.
|
|
20
|
+
|
|
21
|
+
Design constraints (deliberate):
|
|
22
|
+
|
|
23
|
+
* **Cheap.** One JSON file under the data dir, atomic replace on write, a
|
|
24
|
+
single ``threading.Lock`` — no background threads, no new dependencies.
|
|
25
|
+
* **Never breaks the product.** Every public method swallows I/O errors;
|
|
26
|
+
metrics are advisory observability, not a gate.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import logging
|
|
33
|
+
import os
|
|
34
|
+
import tempfile
|
|
35
|
+
import threading
|
|
36
|
+
from datetime import datetime, timezone
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Any, Dict, Optional
|
|
39
|
+
|
|
40
|
+
LOGGER = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
COUNTER_NAMES = (
|
|
43
|
+
"file_requests",
|
|
44
|
+
"real_file_delivered",
|
|
45
|
+
"code_only_responses",
|
|
46
|
+
"agent_runs",
|
|
47
|
+
"needs_review_runs",
|
|
48
|
+
"ingest_completions",
|
|
49
|
+
"recall_successes",
|
|
50
|
+
"approval_pauses",
|
|
51
|
+
"approval_resumes",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
_FIRST_NAMES = ("first_ingest_at", "first_value_at")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _utc_now_iso() -> str:
|
|
58
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _parse_iso(value: Any) -> Optional[datetime]:
|
|
62
|
+
if not value:
|
|
63
|
+
return None
|
|
64
|
+
try:
|
|
65
|
+
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
66
|
+
except ValueError:
|
|
67
|
+
return None
|
|
68
|
+
if parsed.tzinfo is None:
|
|
69
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
70
|
+
return parsed
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _rate(numerator: int, denominator: int) -> Optional[float]:
|
|
74
|
+
"""Honest rate: ``None`` (not 0.0) when there is no denominator yet."""
|
|
75
|
+
if denominator <= 0:
|
|
76
|
+
return None
|
|
77
|
+
return round(numerator / denominator, 4)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class FunnelMetricsService:
|
|
81
|
+
"""Thread-safe, JSON-persisted funnel counters."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, path: Any) -> None:
|
|
84
|
+
self._path = Path(path)
|
|
85
|
+
self._lock = threading.Lock()
|
|
86
|
+
self._state: Dict[str, Any] = self._load()
|
|
87
|
+
|
|
88
|
+
# ── persistence ──────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
def _load(self) -> Dict[str, Any]:
|
|
91
|
+
state: Dict[str, Any] = {name: 0 for name in COUNTER_NAMES}
|
|
92
|
+
for name in _FIRST_NAMES:
|
|
93
|
+
state[name] = None
|
|
94
|
+
try:
|
|
95
|
+
raw = json.loads(self._path.read_text(encoding="utf-8"))
|
|
96
|
+
except FileNotFoundError:
|
|
97
|
+
return state
|
|
98
|
+
except Exception as exc: # noqa: BLE001 — corrupt metrics never break startup
|
|
99
|
+
LOGGER.warning("funnel metrics load failed (%s); starting fresh", exc)
|
|
100
|
+
return state
|
|
101
|
+
if isinstance(raw, dict):
|
|
102
|
+
for name in COUNTER_NAMES:
|
|
103
|
+
try:
|
|
104
|
+
state[name] = max(0, int(raw.get(name) or 0))
|
|
105
|
+
except (TypeError, ValueError):
|
|
106
|
+
state[name] = 0
|
|
107
|
+
for name in _FIRST_NAMES:
|
|
108
|
+
value = raw.get(name)
|
|
109
|
+
state[name] = str(value) if value else None
|
|
110
|
+
return state
|
|
111
|
+
|
|
112
|
+
def _save_locked(self) -> None:
|
|
113
|
+
try:
|
|
114
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
116
|
+
prefix=self._path.name, dir=str(self._path.parent)
|
|
117
|
+
)
|
|
118
|
+
try:
|
|
119
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
120
|
+
json.dump(self._state, handle, ensure_ascii=False, indent=2)
|
|
121
|
+
os.replace(tmp_name, self._path)
|
|
122
|
+
finally:
|
|
123
|
+
if os.path.exists(tmp_name):
|
|
124
|
+
try:
|
|
125
|
+
os.unlink(tmp_name)
|
|
126
|
+
except OSError:
|
|
127
|
+
pass
|
|
128
|
+
except Exception as exc: # noqa: BLE001 — metrics persistence is best-effort
|
|
129
|
+
LOGGER.warning("funnel metrics save failed: %s", exc)
|
|
130
|
+
|
|
131
|
+
# ── mutation ─────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
def increment(self, name: str, by: int = 1) -> None:
|
|
134
|
+
"""Increment a known counter; unknown names are ignored (logged)."""
|
|
135
|
+
if name not in COUNTER_NAMES:
|
|
136
|
+
LOGGER.warning("funnel metrics: unknown counter %r ignored", name)
|
|
137
|
+
return
|
|
138
|
+
try:
|
|
139
|
+
step = int(by)
|
|
140
|
+
except (TypeError, ValueError):
|
|
141
|
+
step = 1
|
|
142
|
+
if step <= 0:
|
|
143
|
+
return
|
|
144
|
+
with self._lock:
|
|
145
|
+
self._state[name] = int(self._state.get(name) or 0) + step
|
|
146
|
+
self._save_locked()
|
|
147
|
+
|
|
148
|
+
def _mark_first_locked(self, name: str) -> None:
|
|
149
|
+
if not self._state.get(name):
|
|
150
|
+
self._state[name] = _utc_now_iso()
|
|
151
|
+
|
|
152
|
+
def record_ingest(self, *, duplicate: bool = False) -> None:
|
|
153
|
+
"""A successful ingestion completed; the first one starts the TTFV clock."""
|
|
154
|
+
with self._lock:
|
|
155
|
+
self._state["ingest_completions"] = (
|
|
156
|
+
int(self._state.get("ingest_completions") or 0) + 1
|
|
157
|
+
)
|
|
158
|
+
self._mark_first_locked("first_ingest_at")
|
|
159
|
+
self._save_locked()
|
|
160
|
+
|
|
161
|
+
def record_recall_success(self) -> None:
|
|
162
|
+
"""A chat answer was grounded in the Brain; the first one ends TTFV."""
|
|
163
|
+
with self._lock:
|
|
164
|
+
self._state["recall_successes"] = (
|
|
165
|
+
int(self._state.get("recall_successes") or 0) + 1
|
|
166
|
+
)
|
|
167
|
+
# First value only counts after knowledge actually entered the Brain.
|
|
168
|
+
if self._state.get("first_ingest_at"):
|
|
169
|
+
self._mark_first_locked("first_value_at")
|
|
170
|
+
self._save_locked()
|
|
171
|
+
|
|
172
|
+
# ── reads ────────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
def ttfv_seconds(self) -> Optional[float]:
|
|
175
|
+
with self._lock:
|
|
176
|
+
first_ingest = _parse_iso(self._state.get("first_ingest_at"))
|
|
177
|
+
first_value = _parse_iso(self._state.get("first_value_at"))
|
|
178
|
+
if first_ingest is None or first_value is None:
|
|
179
|
+
return None
|
|
180
|
+
delta = (first_value - first_ingest).total_seconds()
|
|
181
|
+
return round(delta, 1) if delta >= 0 else None
|
|
182
|
+
|
|
183
|
+
def snapshot(self) -> Dict[str, Any]:
|
|
184
|
+
"""Counters + derived rates for the admin surface."""
|
|
185
|
+
with self._lock:
|
|
186
|
+
counters = {name: int(self._state.get(name) or 0) for name in COUNTER_NAMES}
|
|
187
|
+
firsts = {name: self._state.get(name) for name in _FIRST_NAMES}
|
|
188
|
+
return {
|
|
189
|
+
"counters": counters,
|
|
190
|
+
"firsts": firsts,
|
|
191
|
+
"rates": {
|
|
192
|
+
"real_file_rate": _rate(
|
|
193
|
+
counters["real_file_delivered"], counters["file_requests"]
|
|
194
|
+
),
|
|
195
|
+
"code_only_rate": _rate(
|
|
196
|
+
counters["code_only_responses"], counters["file_requests"]
|
|
197
|
+
),
|
|
198
|
+
"needs_review_rate": _rate(
|
|
199
|
+
counters["needs_review_runs"], counters["agent_runs"]
|
|
200
|
+
),
|
|
201
|
+
"approval_resume_rate": _rate(
|
|
202
|
+
counters["approval_resumes"], counters["approval_pauses"]
|
|
203
|
+
),
|
|
204
|
+
},
|
|
205
|
+
"ttfv_seconds": self.ttfv_seconds(),
|
|
206
|
+
"generated_at": _utc_now_iso(),
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
__all__ = ["FunnelMetricsService", "COUNTER_NAMES"]
|
|
@@ -7,7 +7,11 @@ keyword search into UI-ready contracts without tying routers to store internals.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
from dataclasses import dataclass
|
|
10
|
-
from
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Any, Dict, List, Mapping, Optional
|
|
12
|
+
|
|
13
|
+
from lattice_brain.graph._kg_fsutil import _parse_iso, _recency_score
|
|
14
|
+
from lattice_brain.graph.retrieval_policy import resolve_policy
|
|
11
15
|
|
|
12
16
|
|
|
13
17
|
DEFAULT_HYBRID_WEIGHTS = {
|
|
@@ -402,24 +406,44 @@ class SearchService:
|
|
|
402
406
|
allowed_workspaces=None,
|
|
403
407
|
include_legacy_global: bool = False,
|
|
404
408
|
) -> Dict[str, Any]:
|
|
405
|
-
|
|
409
|
+
# Single retrieval policy (review Wave 0.2): when the caller does not
|
|
410
|
+
# pin explicit weights, resolve the query class + per-class channel
|
|
411
|
+
# weights + deterministic rewrite from lattice_brain.graph.
|
|
412
|
+
# retrieval_policy (the same policy the graph-layer hybrid consults).
|
|
413
|
+
# Explicit weights still win (and disable rewrite/decay), and the
|
|
414
|
+
# "fact" class equals DEFAULT_HYBRID_WEIGHTS, so pinned-weight callers
|
|
415
|
+
# and fact-class queries behave exactly as before.
|
|
416
|
+
query_class: Optional[str] = None
|
|
417
|
+
search_query = query
|
|
418
|
+
rewrite_rules: List[str] = []
|
|
419
|
+
recency_half_life_days: Optional[float] = None
|
|
420
|
+
if weights is None:
|
|
421
|
+
policy = resolve_policy(query)
|
|
422
|
+
query_class = policy["query_class"]
|
|
423
|
+
weights = dict(policy["weights"])
|
|
424
|
+
rewrite_rules = list(policy["rewrite_rules"])
|
|
425
|
+
recency_half_life_days = policy["recency_half_life_days"]
|
|
426
|
+
if policy["search_query"] and policy["search_query"] != policy["original_query"]:
|
|
427
|
+
search_query = policy["search_query"]
|
|
428
|
+
else:
|
|
429
|
+
weights = {**DEFAULT_HYBRID_WEIGHTS, **dict(weights)}
|
|
406
430
|
# Scope each channel at the source so out-of-scope rows never enter the
|
|
407
431
|
# fusion set (defense-in-depth — the fused result is re-scoped below too).
|
|
408
432
|
channels = {
|
|
409
433
|
"keyword": self.keyword_search(
|
|
410
|
-
|
|
434
|
+
search_query,
|
|
411
435
|
limit=keyword_limit,
|
|
412
436
|
allowed_workspaces=allowed_workspaces,
|
|
413
437
|
include_legacy_global=include_legacy_global,
|
|
414
438
|
),
|
|
415
439
|
"vector": self.vector_search(
|
|
416
|
-
|
|
440
|
+
search_query,
|
|
417
441
|
limit=vector_limit,
|
|
418
442
|
allowed_workspaces=allowed_workspaces,
|
|
419
443
|
include_legacy_global=include_legacy_global,
|
|
420
444
|
),
|
|
421
445
|
"graph": self.graph_search(
|
|
422
|
-
|
|
446
|
+
search_query,
|
|
423
447
|
limit=graph_limit,
|
|
424
448
|
allowed_workspaces=allowed_workspaces,
|
|
425
449
|
include_legacy_global=include_legacy_global,
|
|
@@ -452,6 +476,24 @@ class SearchService:
|
|
|
452
476
|
current.setdefault("graph_context", [])
|
|
453
477
|
current["graph_context"].extend(result.get("graph_context") or [])
|
|
454
478
|
|
|
479
|
+
# Recency-class age decay (retrieval_policy): dampen each fused score
|
|
480
|
+
# into the [0.5, 1.0] band so old-but-relevant items sink without ever
|
|
481
|
+
# being zeroed. Missing/unparseable updated_at keeps multiplier 1.0 —
|
|
482
|
+
# unknown age is not evidence of staleness. Other classes skip this
|
|
483
|
+
# block byte-identically.
|
|
484
|
+
if recency_half_life_days is not None:
|
|
485
|
+
decay_now = datetime.now()
|
|
486
|
+
for item in fused.values():
|
|
487
|
+
stamp = item.get("updated_at")
|
|
488
|
+
if _parse_iso(stamp):
|
|
489
|
+
multiplier = 0.5 + 0.5 * _recency_score(
|
|
490
|
+
stamp, now=decay_now, half_life_days=recency_half_life_days
|
|
491
|
+
)
|
|
492
|
+
else:
|
|
493
|
+
multiplier = 1.0
|
|
494
|
+
item["source_scores"]["age_decay"] = round(multiplier, 6)
|
|
495
|
+
item["score"] = float(item["score"]) * multiplier
|
|
496
|
+
|
|
455
497
|
matches = self._scope(
|
|
456
498
|
sorted(fused.values(), key=lambda item: item["score"], reverse=True),
|
|
457
499
|
allowed_workspaces,
|
|
@@ -464,10 +506,14 @@ class SearchService:
|
|
|
464
506
|
"weights": weights,
|
|
465
507
|
"sources": match.get("sources", []),
|
|
466
508
|
}
|
|
509
|
+
if query_class is not None:
|
|
510
|
+
match["fusion"]["query_class"] = query_class
|
|
467
511
|
return {
|
|
468
512
|
"query": query,
|
|
469
513
|
"mode": "hybrid",
|
|
514
|
+
"query_class": query_class,
|
|
470
515
|
"weights": weights,
|
|
516
|
+
"policy": {"search_query": search_query, "rewrite_rules": rewrite_rules},
|
|
471
517
|
"channels": {
|
|
472
518
|
name: {
|
|
473
519
|
key: value
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Built-in First Value Loop demo corpus (backlog #3, review §3.3 P0).
|
|
2
|
+
|
|
3
|
+
Three small Korean-friendly documents shipped in-repo so a brand-new user can
|
|
4
|
+
one-click seed the Brain and immediately experience a successful recall with
|
|
5
|
+
source highlighting — no folder connection, no model download, fully offline.
|
|
6
|
+
|
|
7
|
+
Contract:
|
|
8
|
+
|
|
9
|
+
* Documents are ingested through the normal :class:`IngestionPipeline`
|
|
10
|
+
(``source_type="note"``) with ``source_uri = demo://<id>`` and metadata
|
|
11
|
+
``{"demo_corpus": true}`` so they are identifiable and removable later.
|
|
12
|
+
* Ingestion is idempotent — the graph layer dedupes by content hash, so
|
|
13
|
+
re-POSTing never duplicates documents.
|
|
14
|
+
* :data:`SUGGESTED_QUESTIONS` are pre-filled questions whose answers exist in
|
|
15
|
+
the corpus (the UI renders them as "ask this" chips).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Any, Dict, List
|
|
21
|
+
|
|
22
|
+
DEMO_URI_PREFIX = "demo://"
|
|
23
|
+
DEMO_METADATA_FLAG = "demo_corpus"
|
|
24
|
+
|
|
25
|
+
DEMO_DOCUMENTS: List[Dict[str, str]] = [
|
|
26
|
+
{
|
|
27
|
+
"id": "meeting-note",
|
|
28
|
+
"title": "주간 회의록 — 사이드 프로젝트 킥오프",
|
|
29
|
+
"text": (
|
|
30
|
+
"2026-07-20 주간 회의록.\n"
|
|
31
|
+
"참석: 나, 김민준(백엔드), 박서연(디자인).\n"
|
|
32
|
+
"핵심 결정: 사이드 프로젝트 '새싹 가든'의 첫 공개 버전을 8월 15일에 "
|
|
33
|
+
"출시하기로 결정했다. 범위는 식물 기록과 물주기 알림 두 가지로 줄인다.\n"
|
|
34
|
+
"김민준이 알림 백엔드를 맡고, 박서연이 온보딩 화면을 맡는다.\n"
|
|
35
|
+
"다음 회의 전까지 각자 프로토타입을 준비하기로 했다."
|
|
36
|
+
),
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"id": "project-doc",
|
|
40
|
+
"title": "프로젝트 개요 — 새싹 가든",
|
|
41
|
+
"text": (
|
|
42
|
+
"새싹 가든은 집에서 키우는 식물을 기록하는 작은 앱이다.\n"
|
|
43
|
+
"기술 스택: 프론트엔드는 React, 백엔드는 FastAPI, 데이터는 SQLite에 "
|
|
44
|
+
"로컬로 저장한다. 사진은 기기 밖으로 나가지 않는다.\n"
|
|
45
|
+
"첫 버전 목표: 식물 등록, 물주기 알림, 한 줄 관찰 일기.\n"
|
|
46
|
+
"수익화는 생각하지 않고, 주말에 만드는 것을 원칙으로 한다."
|
|
47
|
+
),
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"id": "personal-note",
|
|
51
|
+
"title": "개인 노트 — 독서 메모: 아주 작은 습관의 힘",
|
|
52
|
+
"text": (
|
|
53
|
+
"『아주 작은 습관의 힘』을 읽고 남긴 메모.\n"
|
|
54
|
+
"가장 기억에 남는 문장: 습관은 목표가 아니라 시스템으로 만들어진다.\n"
|
|
55
|
+
"적용해 볼 것: 매일 아침 10분 스트레칭을 양치 직후에 붙여서 시작한다.\n"
|
|
56
|
+
"핵심은 2분 규칙 — 새 습관은 2분 안에 끝나는 크기로 시작하는 것이다."
|
|
57
|
+
),
|
|
58
|
+
},
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
# Pre-filled questions whose answers exist in the corpus above. ``source_uri``
|
|
62
|
+
# lets the UI verify the recall cited the right demo document.
|
|
63
|
+
SUGGESTED_QUESTIONS: List[Dict[str, str]] = [
|
|
64
|
+
{
|
|
65
|
+
"question": "회의에서 결정한 출시일이 언제야?",
|
|
66
|
+
"expected_source_uri": DEMO_URI_PREFIX + "meeting-note",
|
|
67
|
+
"expected_title": "주간 회의록 — 사이드 프로젝트 킥오프",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"question": "새싹 가든의 기술 스택이 뭐야?",
|
|
71
|
+
"expected_source_uri": DEMO_URI_PREFIX + "project-doc",
|
|
72
|
+
"expected_title": "프로젝트 개요 — 새싹 가든",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"question": "새 습관을 시작할 때 쓰는 2분 규칙이 뭐였지?",
|
|
76
|
+
"expected_source_uri": DEMO_URI_PREFIX + "personal-note",
|
|
77
|
+
"expected_title": "개인 노트 — 독서 메모: 아주 작은 습관의 힘",
|
|
78
|
+
},
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def demo_source_uri(doc_id: str) -> str:
|
|
83
|
+
return DEMO_URI_PREFIX + str(doc_id)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def suggested_questions() -> List[Dict[str, Any]]:
|
|
87
|
+
"""Copy of the suggestion chips (callers may annotate freely)."""
|
|
88
|
+
return [dict(item) for item in SUGGESTED_QUESTIONS]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
__all__ = [
|
|
92
|
+
"DEMO_DOCUMENTS",
|
|
93
|
+
"DEMO_METADATA_FLAG",
|
|
94
|
+
"DEMO_URI_PREFIX",
|
|
95
|
+
"SUGGESTED_QUESTIONS",
|
|
96
|
+
"demo_source_uri",
|
|
97
|
+
"suggested_questions",
|
|
98
|
+
]
|
|
@@ -6,11 +6,13 @@ Path resolution reads ``latticeai.tools.AGENT_ROOT`` so tests can redirect the s
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import io
|
|
9
10
|
import json
|
|
10
11
|
import re
|
|
12
|
+
import zipfile
|
|
11
13
|
from html.parser import HTMLParser
|
|
12
14
|
from pathlib import Path
|
|
13
|
-
from typing import Any, Dict, List, Optional
|
|
15
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
14
16
|
|
|
15
17
|
import latticeai.tools as tools
|
|
16
18
|
from latticeai.tools import (
|
|
@@ -554,3 +556,38 @@ button { padding: 10px 14px; border-radius: 10px; border: 1px solid #d6d6d6; bac
|
|
|
554
556
|
"file_count": len(created),
|
|
555
557
|
"bytes": total_bytes,
|
|
556
558
|
}
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
# Generous cap for a generated-project archive — the workspace itself caps
|
|
562
|
+
# individual files at MAX_FILE_BYTES, this only bounds pathological trees.
|
|
563
|
+
MAX_ZIP_TOTAL_BYTES = 50_000_000
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def zip_workspace_dir(path: str, max_total_bytes: int = MAX_ZIP_TOTAL_BYTES) -> Tuple[bytes, str]:
|
|
567
|
+
"""Zip one workspace directory for download; confinement-safe by design.
|
|
568
|
+
|
|
569
|
+
Path resolution goes through the sandboxed ``_resolve_path`` (traversal
|
|
570
|
+
outside the agent workspace raises ``ToolError``), symlinks are skipped so
|
|
571
|
+
an in-workspace link can never leak files from elsewhere, and the archive
|
|
572
|
+
root is the directory name itself (``todo-app/index.html``).
|
|
573
|
+
"""
|
|
574
|
+
target = _resolve_path(path)
|
|
575
|
+
if target == tools.AGENT_ROOT:
|
|
576
|
+
raise ToolError("Cannot zip the entire workspace root — pick a project directory.")
|
|
577
|
+
if not target.exists() or not target.is_dir():
|
|
578
|
+
raise ToolError("Path is not a directory in the workspace.")
|
|
579
|
+
|
|
580
|
+
buffer = io.BytesIO()
|
|
581
|
+
total = 0
|
|
582
|
+
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
|
|
583
|
+
for child in sorted(target.rglob("*")):
|
|
584
|
+
if child.is_symlink() or not child.is_file():
|
|
585
|
+
continue
|
|
586
|
+
resolved = child.resolve()
|
|
587
|
+
if target not in resolved.parents:
|
|
588
|
+
continue # defense in depth — never archive escaped paths
|
|
589
|
+
total += child.stat().st_size
|
|
590
|
+
if total > max_total_bytes:
|
|
591
|
+
raise ToolError("Directory is too large to zip.")
|
|
592
|
+
archive.write(child, arcname=str(child.relative_to(target.parent)))
|
|
593
|
+
return buffer.getvalue(), f"{target.name}.zip"
|
package/package.json
CHANGED