ltcai 9.9.1 → 9.9.3

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.
Files changed (97) hide show
  1. package/README.md +40 -39
  2. package/docs/CHANGELOG.md +80 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +1 -1
  7. package/docs/TRUST_MODEL.md +1 -1
  8. package/docs/WHY_LATTICE.md +1 -1
  9. package/docs/kg-schema.md +1 -1
  10. package/lattice_brain/__init__.py +1 -1
  11. package/lattice_brain/graph/curator.py +141 -0
  12. package/lattice_brain/graph/documents.py +111 -0
  13. package/lattice_brain/graph/fusion.py +179 -0
  14. package/lattice_brain/graph/projection.py +161 -0
  15. package/lattice_brain/graph/retrieval.py +19 -1
  16. package/lattice_brain/ingestion.py +66 -0
  17. package/lattice_brain/runtime/multi_agent.py +1 -1
  18. package/latticeai/__init__.py +1 -1
  19. package/latticeai/api/automation_intelligence.py +159 -0
  20. package/latticeai/api/browser.py +15 -4
  21. package/latticeai/api/chat.py +30 -2
  22. package/latticeai/api/chat_agent_http.py +193 -4
  23. package/latticeai/api/chat_contracts.py +7 -1
  24. package/latticeai/api/chat_helpers.py +147 -0
  25. package/latticeai/api/chat_intents.py +319 -8
  26. package/latticeai/api/chat_stream.py +17 -1
  27. package/latticeai/api/funnel_metrics.py +31 -0
  28. package/latticeai/api/knowledge_graph.py +30 -0
  29. package/latticeai/api/local_files.py +90 -0
  30. package/latticeai/api/setup.py +134 -1
  31. package/latticeai/api/tools.py +33 -3
  32. package/latticeai/app_factory.py +15 -0
  33. package/latticeai/core/agent.py +205 -11
  34. package/latticeai/core/agent_eval.py +107 -0
  35. package/latticeai/core/file_generation.py +337 -2
  36. package/latticeai/core/legacy_compatibility.py +1 -1
  37. package/latticeai/core/marketplace.py +1 -1
  38. package/latticeai/core/workspace_os.py +1 -1
  39. package/latticeai/runtime/persistence_runtime.py +19 -1
  40. package/latticeai/runtime/router_registration.py +8 -1
  41. package/latticeai/services/app_context.py +6 -0
  42. package/latticeai/services/architecture_readiness.py +1 -1
  43. package/latticeai/services/automation_execution.py +266 -0
  44. package/latticeai/services/automation_intelligence.py +7 -0
  45. package/latticeai/services/command_center.py +20 -1
  46. package/latticeai/services/folder_watch.py +391 -0
  47. package/latticeai/services/funnel_metrics.py +202 -0
  48. package/latticeai/services/product_readiness.py +6 -1
  49. package/latticeai/services/search_service.py +17 -1
  50. package/latticeai/services/tool_dispatch.py +36 -0
  51. package/latticeai/setup/demo_corpus.py +98 -0
  52. package/latticeai/tools/filesystem.py +38 -1
  53. package/package.json +1 -1
  54. package/scripts/bench_models.py +234 -5
  55. package/scripts/check_current_release_docs.mjs +1 -1
  56. package/src-tauri/Cargo.lock +1 -1
  57. package/src-tauri/Cargo.toml +1 -1
  58. package/src-tauri/tauri.conf.json +1 -1
  59. package/static/app/asset-manifest.json +28 -27
  60. package/static/app/assets/Act-DmdruVKV.js +1 -0
  61. package/static/app/assets/{Brain-CSCDZEHd.js → Brain-DEY9jLVt.js} +2 -2
  62. package/static/app/assets/BrainHome-CeNaxjP1.js +2 -0
  63. package/static/app/assets/BrainSignals-CStjIqYi.js +1 -0
  64. package/static/app/assets/{Capture-BiuerOio.js → Capture-CVZ09QXi.js} +1 -1
  65. package/static/app/assets/CommandPalette-DepwOQFv.js +1 -0
  66. package/static/app/assets/Library-Bp0n-HlW.js +1 -0
  67. package/static/app/assets/{LivingBrain-6FvA0H5T.js → LivingBrain-DxP4efJF.js} +1 -1
  68. package/static/app/assets/{ProductFlow-Cr8bkQPw.js → ProductFlow-DRbm7NEq.js} +1 -1
  69. package/static/app/assets/ReviewCard-C4HAO7A3.js +3 -0
  70. package/static/app/assets/{System-D6uJacqu.js → System-ByQcmJW-.js} +1 -1
  71. package/static/app/assets/{bot-DgC7xgUl.js → bot-BNDyZLR7.js} +1 -1
  72. package/static/app/assets/circle-play-BkhdcHgd.js +1 -0
  73. package/static/app/assets/{cpu-DDGb4XHl.js → cpu-C6jjYm6i.js} +1 -1
  74. package/static/app/assets/{folder-open-BbVuSO9M.js → folder-open-DjGIvDBQ.js} +1 -1
  75. package/static/app/assets/{hard-drive-yDZR6XYz.js → hard-drive-BlSbwSaT.js} +1 -1
  76. package/static/app/assets/index-Bge3DXW7.css +2 -0
  77. package/static/app/assets/{index-DrZSkwrE.js → index-CHu7cgj3.js} +3 -3
  78. package/static/app/assets/{input-DPIkTs9n.js → input-DVDI0YR3.js} +1 -1
  79. package/static/app/assets/{navigation-B1mkmyUe.js → navigation-BddhEWA0.js} +1 -1
  80. package/static/app/assets/{network-0XDI1Uf_.js → network-pYQt5oBu.js} +1 -1
  81. package/static/app/assets/{primitives-sIQYKyf5.js → primitives-D7gCdEvS.js} +1 -1
  82. package/static/app/assets/shield-alert-K9RKGQeg.js +1 -0
  83. package/static/app/assets/{textarea-DvnwpnqS.js → textarea-sqQmoBKL.js} +1 -1
  84. package/static/app/assets/useFocusTrap-7EV9dFP2.js +1 -0
  85. package/static/app/assets/utils-uQYKXNeq.js +7 -0
  86. package/static/app/index.html +4 -4
  87. package/static/sw.js +1 -1
  88. package/static/app/assets/Act-CcSWTQsx.js +0 -1
  89. package/static/app/assets/BrainHome-B5QpJ1Fx.js +0 -2
  90. package/static/app/assets/BrainSignals-CmFD8PWk.js +0 -1
  91. package/static/app/assets/CommandPalette-BrqpT7K2.js +0 -1
  92. package/static/app/assets/Library-BcaiPbum.js +0 -1
  93. package/static/app/assets/ReviewCard-D56TMypk.js +0 -1
  94. package/static/app/assets/download-C-Ei3GFa.js +0 -1
  95. package/static/app/assets/index-CFRhNBuH.css +0 -2
  96. package/static/app/assets/sunrise-DXMjueec.js +0 -1
  97. package/static/app/assets/utils-BrfFHAJl.js +0 -7
@@ -0,0 +1,179 @@
1
+ """Query-class aware retrieval fusion weights (backlog #5, review §7.2 E).
2
+
3
+ Hybrid retrieval fuses keyword/lexical, vector, and graph channels. A single
4
+ static weight set treats "이 함수 왜 죽어?" and "어제 회의에서 뭐 결정했지?"
5
+ identically, which is measurably wrong: code questions live on exact
6
+ identifiers (lexical), person questions lean on relationship edges (graph),
7
+ recency questions need fresher lexical hits over semantic neighbors.
8
+
9
+ This module is deliberately tiny and deterministic:
10
+
11
+ * :func:`classify_query` — pure ko/en heuristics → ``fact | code | person |
12
+ recency`` (``fact`` is the fallback class and its weights are identical to
13
+ the historical defaults, so fact-class behavior is byte-compatible).
14
+ * :data:`DEFAULT_FUSION_WEIGHTS` — the per-class weight table. ``keyword`` /
15
+ ``vector`` / ``graph`` feed the three-channel service fusion
16
+ (``SearchService.hybrid_search``); ``alpha`` is the vector share for the
17
+ two-channel graph-layer fusion (``KnowledgeGraphStore.hybrid_search``).
18
+ * :func:`fusion_profile` — classify + resolve weights, honoring the
19
+ ``LATTICEAI_FUSION_WEIGHTS`` env override (JSON, partial per-class merges,
20
+ e.g. ``{"code": {"alpha": 0.2}}``).
21
+
22
+ Never uses ``or`` on scores (score 0 is falsy but valid) and never raises:
23
+ a malformed override is ignored, an unknown class falls back to ``fact``.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import os
30
+ import re
31
+ from typing import Any, Dict, Mapping, Optional
32
+
33
+ QUERY_CLASSES = ("fact", "code", "person", "recency")
34
+
35
+ FUSION_WEIGHTS_ENV = "LATTICEAI_FUSION_WEIGHTS"
36
+
37
+ # Per-class fusion weights. "fact" mirrors the pre-fusion-gate defaults
38
+ # (SearchService DEFAULT_HYBRID_WEIGHTS + graph-layer alpha=0.6) so the
39
+ # fallback class introduces zero behavioral drift.
40
+ DEFAULT_FUSION_WEIGHTS: Dict[str, Dict[str, float]] = {
41
+ "fact": {"keyword": 0.35, "vector": 0.40, "graph": 0.25, "alpha": 0.60},
42
+ # Code recall lives on exact identifiers/tokens → lexical dominates.
43
+ "code": {"keyword": 0.55, "vector": 0.25, "graph": 0.20, "alpha": 0.35},
44
+ # Person questions are relationship questions → graph channel leads.
45
+ "person": {"keyword": 0.30, "vector": 0.30, "graph": 0.40, "alpha": 0.45},
46
+ # Recency questions want fresh literal hits over semantic neighbors.
47
+ "recency": {"keyword": 0.45, "vector": 0.35, "graph": 0.20, "alpha": 0.50},
48
+ }
49
+
50
+ # ── query-class heuristics (pure, ko/en) ─────────────────────────────────────
51
+
52
+ _CODE_FENCE_RE = re.compile(r"```|`[^`\n]+`")
53
+ _CODE_IDENT_RE = re.compile(
54
+ # ``\b`` fails between an identifier and an attached Korean particle
55
+ # ("ingest_folder가"), so use explicit ASCII-boundary lookbehinds instead.
56
+ r"[A-Za-z_][A-Za-z0-9_]*\(\)" # call syntax foo()
57
+ r"|(?<![A-Za-z0-9_])[a-z0-9]+_[a-z0-9_]+" # snake_case
58
+ r"|(?<![A-Za-z0-9_])[a-z]+[A-Z][A-Za-z0-9]*" # camelCase
59
+ r"|(?<![A-Za-z0-9_])[\w-]+\.(?:py|js|jsx|ts|tsx|json|yaml|yml|css|html|sql|sh|go|rs|java|rb)(?![A-Za-z0-9])"
60
+ )
61
+ _CODE_WORD_RE = re.compile(
62
+ r"\b(?:def|class|import|function|traceback|exception|stack\s*trace|"
63
+ r"bug|error|null|undefined|api|sql|regex)\b"
64
+ r"|코드|함수|버그|에러|오류|스택|컴파일|빌드\s*실패|구현",
65
+ re.IGNORECASE,
66
+ )
67
+ _PERSON_RE = re.compile(
68
+ r"\b(?:who|whom|whose)\b|누구|어떤\s*사람|담당자|팀원|동료|만난\s*사람"
69
+ r"|[\w.+-]+@[\w-]+\.[\w.]+" # an email address names a person
70
+ r"|[가-힣]{2,4}\s*(?:님|씨)(?:\s|$|[을를이가은는의??.!,])",
71
+ re.IGNORECASE,
72
+ )
73
+ _RECENCY_RE = re.compile(
74
+ r"최근|어제|오늘|그저께|방금|아까|지난\s*주|지난주|지난\s*달|지난달"
75
+ r"|이번\s*주|이번주|이번\s*달|이번달"
76
+ r"|\b(?:recent|recently|yesterday|today|latest|last\s+(?:week|month|night|meeting))\b",
77
+ re.IGNORECASE,
78
+ )
79
+
80
+
81
+ def classify_query(query: Any) -> str:
82
+ """Classify a retrieval query into fact / code / person / recency.
83
+
84
+ Precedence: code (strongest structural signal) → recency (explicit time
85
+ words) → person → fact. Deterministic, never raises, empty → fact.
86
+ """
87
+ text = str(query or "").strip()
88
+ if not text:
89
+ return "fact"
90
+ if _CODE_FENCE_RE.search(text) or _CODE_IDENT_RE.search(text) or _CODE_WORD_RE.search(text):
91
+ return "code"
92
+ if _RECENCY_RE.search(text):
93
+ return "recency"
94
+ if _PERSON_RE.search(text):
95
+ return "person"
96
+ return "fact"
97
+
98
+
99
+ def _env_overrides() -> Dict[str, Dict[str, float]]:
100
+ """Parse LATTICEAI_FUSION_WEIGHTS (JSON) → per-class partial overrides."""
101
+ raw = os.getenv(FUSION_WEIGHTS_ENV, "").strip()
102
+ if not raw:
103
+ return {}
104
+ try:
105
+ payload = json.loads(raw)
106
+ except (ValueError, TypeError):
107
+ return {}
108
+ if not isinstance(payload, dict):
109
+ return {}
110
+ overrides: Dict[str, Dict[str, float]] = {}
111
+ for cls, table in payload.items():
112
+ if cls not in DEFAULT_FUSION_WEIGHTS or not isinstance(table, dict):
113
+ continue
114
+ cleaned: Dict[str, float] = {}
115
+ for key, value in table.items():
116
+ if key not in ("keyword", "vector", "graph", "alpha"):
117
+ continue
118
+ try:
119
+ cleaned[key] = max(0.0, min(1.0, float(value)))
120
+ except (TypeError, ValueError):
121
+ continue
122
+ if cleaned:
123
+ overrides[str(cls)] = cleaned
124
+ return overrides
125
+
126
+
127
+ def fusion_weight_table(
128
+ overrides: Optional[Mapping[str, Mapping[str, float]]] = None,
129
+ ) -> Dict[str, Dict[str, float]]:
130
+ """Full per-class table: defaults ← env override ← caller override."""
131
+ table = {cls: dict(weights) for cls, weights in DEFAULT_FUSION_WEIGHTS.items()}
132
+ for source in (_env_overrides(), overrides or {}):
133
+ for cls, partial in source.items():
134
+ if cls in table and isinstance(partial, Mapping):
135
+ for key, value in partial.items():
136
+ if key in table[cls]:
137
+ try:
138
+ table[cls][key] = max(0.0, min(1.0, float(value)))
139
+ except (TypeError, ValueError):
140
+ continue
141
+ return table
142
+
143
+
144
+ def fusion_profile(
145
+ query: Any,
146
+ *,
147
+ overrides: Optional[Mapping[str, Mapping[str, float]]] = None,
148
+ ) -> Dict[str, Any]:
149
+ """Classify ``query`` and return its resolved fusion weights.
150
+
151
+ Returns ``{"query_class": str, "weights": {keyword, vector, graph},
152
+ "alpha": float}``. ``weights`` feeds the three-channel service fusion;
153
+ ``alpha`` feeds the two-channel graph-layer fusion.
154
+ """
155
+ query_class = classify_query(query)
156
+ table = fusion_weight_table(overrides)
157
+ resolved = table.get(query_class)
158
+ if resolved is None: # defensive — classify_query only emits known classes
159
+ query_class = "fact"
160
+ resolved = table["fact"]
161
+ return {
162
+ "query_class": query_class,
163
+ "weights": {
164
+ "keyword": resolved["keyword"],
165
+ "vector": resolved["vector"],
166
+ "graph": resolved["graph"],
167
+ },
168
+ "alpha": resolved["alpha"],
169
+ }
170
+
171
+
172
+ __all__ = [
173
+ "DEFAULT_FUSION_WEIGHTS",
174
+ "FUSION_WEIGHTS_ENV",
175
+ "QUERY_CLASSES",
176
+ "classify_query",
177
+ "fusion_profile",
178
+ "fusion_weight_table",
179
+ ]
@@ -514,6 +514,167 @@ class KnowledgeGraphProjectionMixin:
514
514
  "skipped_total": len(overlay["skipped"]),
515
515
  }
516
516
 
517
+ _NOISE_CONTENT_TYPES = (
518
+ "Document",
519
+ "File",
520
+ "CodeFile",
521
+ "Message",
522
+ "AIResponse",
523
+ "Chat",
524
+ "Page",
525
+ "Slide",
526
+ "Spreadsheet",
527
+ )
528
+ _NOISE_CONCEPT_TYPES = ("Concept", "Feature", "Topic", "Code", "Error")
529
+
530
+ def curate_noise(
531
+ self,
532
+ *,
533
+ dry_run: bool = True,
534
+ max_df_ratio: float = 0.8,
535
+ min_doc_frequency: int = 1,
536
+ min_corpus_docs: int = 5,
537
+ normalize_verbs: bool = True,
538
+ max_removals: int = 200,
539
+ ) -> Dict[str, Any]:
540
+ """Noise-reduction curation job (backlog #10, review §7.2 D).
541
+
542
+ (a) Removes heuristic concept nodes (``auto_extracted`` /
543
+ ``graph_curator``-promoted) whose document frequency marks them as
544
+ noise: ubiquitous (low IDF — linked from more than ``max_df_ratio`` of
545
+ content docs once the corpus has ``min_corpus_docs``) or below the
546
+ ``min_doc_frequency`` floor. Explicitly user-created nodes are never
547
+ touched, whatever their stats.
548
+
549
+ (b) Normalizes free-string relation verbs on the legacy edge table via
550
+ the ko/en dictionary in :mod:`lattice_brain.graph.curator`
551
+ ('만들다/만든/creates' → 'created', …), merging rows that collide
552
+ after the rename.
553
+
554
+ ``dry_run=True`` (the default) only *reports* what would change.
555
+ """
556
+ from .curator import (
557
+ build_relation_verb_index,
558
+ plan_concept_noise_reduction,
559
+ plan_relation_normalization,
560
+ )
561
+
562
+ max_removals = max(0, int(max_removals))
563
+ # Operate on the legacy write tables directly: they are the mutation
564
+ # target, and raw free-string verbs only exist there (the v4 write
565
+ # door normalizes new edges; the kgv2_* read views collapse
566
+ # legacy_type and would hide exactly the rows this job cleans up).
567
+ nt, et = "nodes", "edges"
568
+ with self._connect() as conn:
569
+ content_ph = ",".join("?" for _ in self._NOISE_CONTENT_TYPES)
570
+ total_docs = conn.execute(
571
+ f"SELECT COUNT(*) AS c FROM {nt} WHERE type IN ({content_ph})",
572
+ self._NOISE_CONTENT_TYPES,
573
+ ).fetchone()["c"]
574
+
575
+ concept_ph = ",".join("?" for _ in self._NOISE_CONCEPT_TYPES)
576
+ concept_rows = conn.execute(
577
+ f"SELECT id, type, title, metadata_json FROM {nt} WHERE type IN ({concept_ph})",
578
+ self._NOISE_CONCEPT_TYPES,
579
+ ).fetchall()
580
+ concepts = []
581
+ for row in concept_rows:
582
+ meta = _safe_loads(row["metadata_json"]) or {}
583
+ heuristic = bool(meta.get("auto_extracted")) or (
584
+ meta.get("source") == "graph_curator" or meta.get("curated") is True
585
+ )
586
+ # Document frequency: distinct *content* nodes linked to this
587
+ # concept in either direction.
588
+ df = conn.execute(
589
+ f"""
590
+ SELECT COUNT(DISTINCT n.id) AS c
591
+ FROM {et} e
592
+ JOIN {nt} n
593
+ ON n.id = CASE WHEN e.to_node = ? THEN e.from_node ELSE e.to_node END
594
+ WHERE (e.to_node = ? OR e.from_node = ?)
595
+ AND n.type IN ({content_ph})
596
+ """,
597
+ (row["id"], row["id"], row["id"], *self._NOISE_CONTENT_TYPES),
598
+ ).fetchone()["c"]
599
+ concepts.append({
600
+ "id": row["id"],
601
+ "label": row["title"],
602
+ "type": row["type"],
603
+ "df": int(df or 0),
604
+ "heuristic": heuristic,
605
+ })
606
+
607
+ plan = plan_concept_noise_reduction(
608
+ concepts,
609
+ total_docs,
610
+ max_df_ratio=max_df_ratio,
611
+ min_doc_frequency=min_doc_frequency,
612
+ min_corpus_docs=min_corpus_docs,
613
+ )
614
+ removals = plan["remove"][:max_removals]
615
+
616
+ verb_index = build_relation_verb_index()
617
+ edge_type_rows = conn.execute(
618
+ f"SELECT DISTINCT type FROM {et}"
619
+ ).fetchall()
620
+ verb_plan = (
621
+ plan_relation_normalization(
622
+ (row["type"] for row in edge_type_rows), index=verb_index,
623
+ )
624
+ if normalize_verbs
625
+ else {}
626
+ )
627
+
628
+ removed_count = 0
629
+ renamed_edges = 0
630
+ if not dry_run:
631
+ for decision in removals:
632
+ node_id = decision["id"]
633
+ conn.execute(
634
+ "DELETE FROM edges WHERE from_node=? OR to_node=?",
635
+ (node_id, node_id),
636
+ )
637
+ conn.execute(
638
+ "DELETE FROM vector_embeddings WHERE item_id=?", (node_id,)
639
+ )
640
+ conn.execute("DELETE FROM nodes WHERE id=?", (node_id,))
641
+ self._v2_delete_nodes(conn, [node_id])
642
+ removed_count += 1
643
+ for original, canonical in verb_plan.items():
644
+ renamed_edges += conn.execute(
645
+ "SELECT COUNT(*) AS c FROM edges WHERE type=?", (original,)
646
+ ).fetchone()["c"]
647
+ # UNIQUE(from_node, to_node, type): merge rows that collide
648
+ # after the rename instead of failing the UPDATE.
649
+ conn.execute(
650
+ "UPDATE OR IGNORE edges SET type=? WHERE type=?",
651
+ (canonical, original),
652
+ )
653
+ conn.execute("DELETE FROM edges WHERE type=?", (original,))
654
+
655
+ return {
656
+ "status": "ok",
657
+ "dry_run": bool(dry_run),
658
+ "total_content_docs": int(total_docs or 0),
659
+ "concepts_examined": len(concepts),
660
+ "remove": removals,
661
+ "remove_total": len(plan["remove"]),
662
+ "kept": len(plan["keep"]),
663
+ "protected_user_nodes": sum(
664
+ 1 for item in plan["keep"] if item.get("reason") == "user_created_protected"
665
+ ),
666
+ "verb_normalizations": verb_plan,
667
+ "applied": {
668
+ "removed_nodes": removed_count,
669
+ "renamed_edges": renamed_edges,
670
+ },
671
+ "thresholds": {
672
+ "max_df_ratio": float(max_df_ratio),
673
+ "min_doc_frequency": int(min_doc_frequency),
674
+ "min_corpus_docs": int(min_corpus_docs),
675
+ },
676
+ }
677
+
517
678
  def mark_superseded(self, old_node_id: str, new_node_id: str) -> Dict[str, Any]:
518
679
  """Record that ``old_node_id`` was replaced by ``new_node_id``.
519
680
 
@@ -450,7 +450,7 @@ class KnowledgeGraphRetrievalMixin:
450
450
  query: str,
451
451
  *,
452
452
  top_k: int = 20,
453
- alpha: float = 0.6,
453
+ alpha: Optional[float] = None,
454
454
  workspace_id: Optional[str] = None,
455
455
  allowed_workspaces=None,
456
456
  include_legacy_global: bool = False,
@@ -476,6 +476,12 @@ class KnowledgeGraphRetrievalMixin:
476
476
 
477
477
  ``workspace_id`` is a convenience for single-workspace callers; the
478
478
  richer ``allowed_workspaces`` set wins when both are provided.
479
+
480
+ ``alpha=None`` (the default) resolves the vector share from the
481
+ query-class fusion table (:mod:`lattice_brain.graph.fusion`):
482
+ fact 0.6 (the historical default) / code 0.35 / person 0.45 /
483
+ recency 0.5, config-overridable via ``LATTICEAI_FUSION_WEIGHTS``.
484
+ Passing an explicit ``alpha`` pins it exactly as before.
479
485
  """
480
486
  query = str(query or "").strip()
481
487
  try:
@@ -483,6 +489,16 @@ class KnowledgeGraphRetrievalMixin:
483
489
  except (TypeError, ValueError):
484
490
  top_k = 20
485
491
  top_k = max(1, min(top_k, 100))
492
+ query_class: Optional[str] = None
493
+ if alpha is None:
494
+ try:
495
+ from .fusion import fusion_profile
496
+
497
+ profile = fusion_profile(query)
498
+ query_class = profile["query_class"]
499
+ alpha = float(profile["alpha"])
500
+ except Exception: # noqa: BLE001 — fusion table must never break search
501
+ alpha = 0.6
486
502
  try:
487
503
  alpha = float(alpha)
488
504
  except (TypeError, ValueError):
@@ -496,6 +512,7 @@ class KnowledgeGraphRetrievalMixin:
496
512
  "query": query,
497
513
  "mode": "hybrid",
498
514
  "alpha": alpha,
515
+ "query_class": query_class,
499
516
  "top_k": top_k,
500
517
  "sources": {"lexical": 0, "vector": 0},
501
518
  "matches": [],
@@ -624,6 +641,7 @@ class KnowledgeGraphRetrievalMixin:
624
641
  "query": query,
625
642
  "mode": mode,
626
643
  "alpha": alpha,
644
+ "query_class": query_class,
627
645
  "top_k": top_k,
628
646
  "sources": {"lexical": len(lexical_matches), "vector": len(vector_matches)},
629
647
  "matches": matches,
@@ -245,6 +245,72 @@ def assess_extraction_quality(
245
245
  return {"score": round(score, 4), "level": _quality_level(score), "reasons": reasons}
246
246
 
247
247
 
248
+ # ── capture quality CTA (backlog #9, review §7.2 C) ──────────────────────────
249
+ # Structured verdict over the same extraction-quality schema the rest of the
250
+ # pipeline uses, so capture surfaces (browser extension, read-url) can render
251
+ # an honest "this capture is thin" CTA instead of silently storing junk.
252
+ CAPTURE_SUGGESTIONS_THIN = ["recapture", "paste_manually", "highlight_source"]
253
+ _CAPTURE_REASON_LABELS = {
254
+ "empty_text": "추출된 본문이 비어 있습니다",
255
+ "very_short_text": "추출된 본문이 매우 짧습니다",
256
+ "short_text": "추출된 본문이 짧습니다",
257
+ "no_sentence_structure": "문장 구조가 거의 없습니다",
258
+ "low_character_diversity": "반복 문자가 대부분입니다",
259
+ "repetitive_lines": "같은 줄이 반복됩니다",
260
+ "repetitive_words": "같은 단어가 반복됩니다",
261
+ "high_whitespace_ratio": "공백이 지나치게 많습니다",
262
+ "fragmented_lines": "줄이 잘게 조각나 있습니다",
263
+ "nav_menu_remnants": "메뉴/내비게이션 잔여물이 많습니다",
264
+ "boilerplate_markers": "상용구 텍스트가 많습니다",
265
+ "no_extracted_text": "추출된 텍스트가 없습니다",
266
+ }
267
+
268
+
269
+ def capture_quality_verdict(
270
+ extraction_quality: Optional[Dict[str, Any]],
271
+ *,
272
+ source_type: Optional[str] = None,
273
+ ) -> Dict[str, Any]:
274
+ """Structured CTA verdict from a pipeline ``extraction_quality`` dict.
275
+
276
+ ``{"status": "thin"|"ok", "reason": str|None, "suggestions": [...],
277
+ "score": float|None, "level": str|None}``. ``thin`` (level == "low", the
278
+ same threshold as the ingest warning) carries actionable suggestions —
279
+ ``recapture`` / ``paste_manually`` / ``highlight_source`` — so the UI can
280
+ offer the user a way to fix the capture instead of hiding the problem.
281
+ Deterministic and never raises; ``None`` input yields an honest ``thin``.
282
+ """
283
+ if not isinstance(extraction_quality, dict):
284
+ return {
285
+ "status": "thin",
286
+ "reason": _CAPTURE_REASON_LABELS["no_extracted_text"],
287
+ "reason_codes": ["no_extracted_text"],
288
+ "suggestions": list(CAPTURE_SUGGESTIONS_THIN),
289
+ "score": None,
290
+ "level": None,
291
+ }
292
+ level = str(extraction_quality.get("level") or "")
293
+ score = extraction_quality.get("score")
294
+ reasons = [str(item) for item in (extraction_quality.get("reasons") or [])]
295
+ thin = level == "low"
296
+ reason = None
297
+ if thin:
298
+ labeled = [
299
+ _CAPTURE_REASON_LABELS[code]
300
+ for code in reasons
301
+ if code in _CAPTURE_REASON_LABELS
302
+ ]
303
+ reason = "; ".join(labeled) if labeled else QUALITY_LOW_WARNING
304
+ return {
305
+ "status": "thin" if thin else "ok",
306
+ "reason": reason,
307
+ "reason_codes": reasons if thin else [],
308
+ "suggestions": list(CAPTURE_SUGGESTIONS_THIN) if thin else [],
309
+ "score": score,
310
+ "level": level or None,
311
+ }
312
+
313
+
248
314
  def _load_latticeignore(root: Path) -> List[str]:
249
315
  """Parse ``root/.latticeignore`` → glob patterns (gitignore-like subset)."""
250
316
  ignore_file = root / LATTICEIGNORE_FILENAME
@@ -48,7 +48,7 @@ from .contracts import multi_agent_contract
48
48
  from ..utils import now_iso as _now
49
49
 
50
50
 
51
- MULTI_AGENT_VERSION = "9.9.1"
51
+ MULTI_AGENT_VERSION = "9.9.3"
52
52
 
53
53
  AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
54
54
  CORE_PIPELINE = ("planner", "executor", "reviewer")
@@ -1,3 +1,3 @@
1
1
  """Lattice AI - modular server package."""
2
2
 
3
- __version__ = "9.9.1"
3
+ __version__ = "9.9.3"
@@ -12,11 +12,21 @@ enable), review-queue gated, local-only, and idempotent per suggestion.
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
+ import asyncio
16
+ import os
17
+ import time
15
18
  from typing import Any, Callable, Optional
16
19
 
17
20
  from fastapi import APIRouter, HTTPException, Request
18
21
  from pydantic import BaseModel
19
22
 
23
+ from latticeai.services.automation_execution import (
24
+ build_last_execution,
25
+ dry_run_report,
26
+ enqueue_failed_execution,
27
+ is_automation_workflow,
28
+ summarize_workflow_run,
29
+ )
20
30
  from latticeai.services.automation_intelligence import AutomationIntelligenceService
21
31
  from latticeai.services.brain_automation import find_installed_recipe_workflow
22
32
 
@@ -26,6 +36,21 @@ class SuggestionInstallRequest(BaseModel):
26
36
  enabled: bool = False
27
37
 
28
38
 
39
+ class AutomationRunNowRequest(BaseModel):
40
+ workflow_id: str
41
+ # Dry-run first: the default reports what WOULD happen without side
42
+ # effects; an explicit dry_run=false runs the automation once for real.
43
+ dry_run: bool = True
44
+
45
+
46
+ def _run_now_wait_seconds() -> float:
47
+ raw = os.environ.get("LATTICEAI_AUTOMATION_RUN_NOW_WAIT", "30")
48
+ try:
49
+ return max(0.0, min(float(raw), 300.0))
50
+ except (TypeError, ValueError):
51
+ return 30.0
52
+
53
+
29
54
  def create_automation_intelligence_router(
30
55
  *,
31
56
  service: AutomationIntelligenceService,
@@ -35,7 +60,10 @@ def create_automation_intelligence_router(
35
60
  gate_write: Callable[[Request], Optional[str]],
36
61
  append_audit_event: Callable[..., None],
37
62
  workspace_graph: Callable[[], Any],
63
+ run_executor: Any = None,
64
+ review_queue: Any = None,
38
65
  ) -> APIRouter:
66
+ from lattice_brain.runtime.statuses import RUN_TERMINAL_STATUSES
39
67
  from lattice_brain.workflow import legacy_steps_from_nodes, validate_definition
40
68
 
41
69
  router = APIRouter()
@@ -123,6 +151,137 @@ def create_automation_intelligence_router(
123
151
  "already_installed": False,
124
152
  }
125
153
 
154
+ def _stamp_last_execution(
155
+ workflow_id: str, last_execution: dict, scope: Optional[str]
156
+ ) -> None:
157
+ """Persist the execution record on the workflow's metadata (merge)."""
158
+ try:
159
+ store.update_workflow_definition(
160
+ workflow_id,
161
+ metadata={"last_execution": last_execution},
162
+ workspace_id=scope,
163
+ )
164
+ except Exception: # noqa: BLE001 — surfacing must never undo the run
165
+ pass
166
+
167
+ @router.post("/api/automation/run-now")
168
+ async def automation_run_now(req: AutomationRunNowRequest, request: Request):
169
+ """Run an installed automation once — dry-run by default.
170
+
171
+ * ``dry_run=true``: deterministic report of what WOULD happen; no
172
+ runner executes and nothing but the ``last_execution`` stamp moves.
173
+ * ``dry_run=false``: one real execution through the async run
174
+ executor; the request waits briefly for completion, stamps the
175
+ result, and enqueues failed executions into the review inbox.
176
+ """
177
+ user = require_user(request)
178
+ scope = gate_write(request)
179
+ try:
180
+ workflow = store.get_workflow(req.workflow_id, workspace_id=scope)
181
+ except FileNotFoundError as exc:
182
+ raise HTTPException(
183
+ status_code=404, detail=f"Automation not found: {req.workflow_id}"
184
+ ) from exc
185
+ if not is_automation_workflow(workflow):
186
+ raise HTTPException(
187
+ status_code=404,
188
+ detail=f"Workflow is not an installed automation: {req.workflow_id}",
189
+ )
190
+
191
+ if req.dry_run:
192
+ report = dry_run_report(workflow)
193
+ last_execution = build_last_execution(
194
+ mode="dry_run",
195
+ status=report["status"],
196
+ summary=report["summary"],
197
+ )
198
+ _stamp_last_execution(req.workflow_id, last_execution, scope)
199
+ append_audit_event(
200
+ "automation_run_now",
201
+ user_email=user,
202
+ workflow_id=req.workflow_id,
203
+ dry_run=True,
204
+ status=report["status"],
205
+ )
206
+ return {
207
+ "workflow_id": req.workflow_id,
208
+ "dry_run": True,
209
+ "status": report["status"],
210
+ "report": report,
211
+ "last_execution": last_execution,
212
+ }
213
+
214
+ if run_executor is None:
215
+ raise HTTPException(
216
+ status_code=503,
217
+ detail="Automation execution runtime is not available.",
218
+ )
219
+ started = await run_executor.start_workflow(
220
+ workflow,
221
+ workflow_id=req.workflow_id,
222
+ user_email=user or None,
223
+ scope=scope,
224
+ inputs={"trigger": "run_now"},
225
+ )
226
+ run_id = str(((started or {}).get("run") or {}).get("id") or "")
227
+
228
+ # Bounded, non-cancelling wait: poll the durable run row so a slow
229
+ # execution keeps running in the background instead of being aborted.
230
+ run = (started or {}).get("run") or {}
231
+ deadline = time.monotonic() + _run_now_wait_seconds()
232
+ while time.monotonic() < deadline:
233
+ try:
234
+ run = store.get_workflow_run(run_id, workspace_id=scope)
235
+ except FileNotFoundError:
236
+ break
237
+ if str(run.get("status") or "") in RUN_TERMINAL_STATUSES:
238
+ break
239
+ await asyncio.sleep(0.05)
240
+
241
+ status = str(run.get("status") or "running")
242
+ completed = status in RUN_TERMINAL_STATUSES
243
+ summary = (
244
+ summarize_workflow_run(run)
245
+ if completed
246
+ else "started — still running in the background"
247
+ )
248
+ last_execution = build_last_execution(
249
+ mode="live",
250
+ status=status if completed else "running",
251
+ summary=summary,
252
+ run_id=run_id or None,
253
+ )
254
+ _stamp_last_execution(req.workflow_id, last_execution, scope)
255
+
256
+ review_item = None
257
+ if status == "failed":
258
+ review_item = enqueue_failed_execution(
259
+ review_queue,
260
+ workflow=workflow,
261
+ run_id=run_id or None,
262
+ error=summary,
263
+ user_email=user or None,
264
+ workspace_id=scope,
265
+ )
266
+ append_audit_event(
267
+ "automation_run_now",
268
+ user_email=user,
269
+ workflow_id=req.workflow_id,
270
+ dry_run=False,
271
+ run_id=run_id or None,
272
+ status=last_execution["status"],
273
+ )
274
+ payload = {
275
+ "workflow_id": req.workflow_id,
276
+ "dry_run": False,
277
+ "status": last_execution["status"],
278
+ "run_id": run_id or None,
279
+ "last_execution": last_execution,
280
+ }
281
+ if review_item is not None:
282
+ payload["review_item_id"] = review_item.get("id")
283
+ return payload
284
+
126
285
  return router
127
286
 
128
287