Skip to content

Commit a8fb023

Browse files
DeepfreezechillBrian KrafftCopilot
authored
feat(P3): Epic 3.5 — TagSearch extraction + /8eyes fixes (#21)
* feat(3.5): extract TagIndex + SearchEngine from monolithic SkillStore Epic 3.5: Decompose monolithic SkillStore by extracting tag indexing and search operations into a dedicated TagSearch module. Changes: - Created openspace/skill_engine/tag_search.py with TagSearch class - Supports standalone usage (owns connection) and shared usage (injected connection/lock) - Extracted methods: sync_tags, get_tags, find_skills_by_tool, find_skills_by_tags, search_skills, get_summary, get_stats, get_top_skills, get_count_and_timestamp - Added comprehensive tag-based search with category/visibility/text filtering - Proper SQL injection prevention with LIKE wildcard escaping - Updated SkillStore to delegate tag/search operations to TagSearch - Updated __init__.py to export TagSearch class - Created comprehensive test suite (37 tests) covering: - Tag CRUD operations - Tag-based skill search (any/all match) - Tool dependency search - Skill discovery and statistics - Complex multi-criteria search - Shared connection patterns (SkillStore integration) - Error handling and edge cases - All 1268 existing tests continue passing - Cleaned up stale diff files Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(3.5): address /8eyes R1 — dedup tags, match_all, facade completeness, field compat Fixes all 6 findings from /8eyes reviewers: Finding 1 (MAJOR): Fixed duplicate tags crash in sync_tags - Added deduplication at start: tags = list(set(tags)) - Added proper rollback on PK constraint failure Finding 2 (MAJOR): Fixed match_all counts with duplicate input - Use unique_tags = list(set(tags)) in find_skills_by_tags() - Use len(unique_tags) in HAVING clause for both find_skills_by_tags and search_skills Finding 3 (MAJOR): Added missing SkillStore facade methods - Added find_skills_by_tags() facade delegation - Added search_skills() facade delegation - Added get_tags() facade delegation - Added get_all_tags() facade delegation - Added sync_tags() facade delegation Finding 4 (MAJOR): Fixed get_stats field name for backward compatibility - Changed 'by_lineage_origin' back to 'by_origin' in TagSearch.get_stats() Finding 5 (MINOR): Fixed f-string parameterization in search_skills HAVING clause - Use parameterized query: 'HAVING COUNT(DISTINCT st.tag) = ?' with params.append(len(unique_tags)) Finding 6 (MINOR): Added comprehensive integration tests - test_sync_tags_with_duplicates — verify deduplication works - test_find_by_tags_with_duplicate_input — verify match_all with dupes - test_search_skills_with_duplicate_tags — verify search_skills match_all - test_skill_store_facade_completeness — verify ALL TagSearch methods accessible - test_facade_method_delegation — verify delegation works - test_skill_store_get_stats_field_names — verify backward-compatible field names - test_tag_search_direct_call_uses_original_field — verify TagSearch field name All tests pass: 1275 passed, 31 skipped Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Brian Krafft <bkrafft@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent a2a3567 commit a8fb023

10 files changed

Lines changed: 1443 additions & 11839 deletions

File tree

openspace/skill_engine/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"SkillRepository",
2121
"LineageTracker",
2222
"AnalysisStore",
23+
"TagSearch",
2324
"ExecutionAnalyzer",
2425
"SkillEvolver",
2526
"EvolutionTrigger",
@@ -46,6 +47,7 @@
4647
"SkillRepository": (".skill_repository", "SkillRepository"),
4748
"LineageTracker": (".lineage_tracker", "LineageTracker"),
4849
"AnalysisStore": (".analysis_store", "AnalysisStore"),
50+
"TagSearch": (".tag_search", "TagSearch"),
4951
"ExecutionAnalyzer": (".analyzer", "ExecutionAnalyzer"),
5052
"SkillEvolver": (".evolver", "SkillEvolver"),
5153
"EvolutionTrigger": (".evolver", "EvolutionTrigger"),

openspace/skill_engine/store.py

Lines changed: 98 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from .lineage_tracker import LineageTracker
3030
from .patch import collect_skill_snapshot, compute_unified_diff
3131
from .skill_repository import SkillRepository
32+
from .tag_search import TagSearch
3233
from .types import (
3334
EvolutionSuggestion,
3435
ExecutionAnalysis,
@@ -205,6 +206,9 @@ def __init__(self, db_path: Optional[Path] = None) -> None:
205206
# Analysis store (Epic 3.4) — delegates execution analyses and judgments.
206207
self._analyses = AnalysisStore(conn=self._conn, lock=self._mu)
207208

209+
# Tag search (Epic 3.5) — delegates tag indexing and skill search operations.
210+
self._tag_search = TagSearch(conn=self._conn, lock=self._mu)
211+
208212
logger.debug(f"SkillStore ready at {self._db_path}")
209213

210214
def _make_connection(self, *, read_only: bool) -> sqlite3.Connection:
@@ -290,6 +294,14 @@ def close(self) -> None:
290294
self._lineage.close()
291295
except Exception:
292296
pass
297+
try:
298+
self._analyses.close()
299+
except Exception:
300+
pass
301+
try:
302+
self._tag_search.close()
303+
except Exception:
304+
pass
293305
try:
294306
# Flush WAL → main DB so external readers see all data
295307
self._conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
@@ -765,16 +777,10 @@ def find_skills_by_tool(self, tool_key: str) -> List[str]:
765777
"""
766778
Only returns active records — deactivated (superseded) versions
767779
are excluded so that Trigger 2 never re-processes old versions.
780+
781+
Delegates to :class:`TagSearch` (Epic 3.5).
768782
"""
769-
with self._reader() as conn:
770-
rows = conn.execute(
771-
"SELECT sd.skill_id "
772-
"FROM skill_tool_deps sd "
773-
"JOIN skill_records sr ON sd.skill_id = sr.skill_id "
774-
"WHERE sd.tool_key=? AND sr.is_active=1",
775-
(tool_key,),
776-
).fetchall()
777-
return [r["skill_id"] for r in rows]
783+
return self._tag_search.find_skills_by_tool(tool_key)
778784

779785
@_db_retry()
780786
def find_children(self, parent_skill_id: str) -> List[str]:
@@ -795,72 +801,29 @@ def get_summary(self, *, active_only: bool = True) -> List[Dict[str, Any]]:
795801
"""Lightweight summary of skills (no analyses/deps loaded).
796802
797803
Default filters to active skills only.
804+
805+
Delegates to :class:`TagSearch` (Epic 3.5).
798806
"""
799-
with self._reader() as conn:
800-
where = "WHERE is_active=1 " if active_only else ""
801-
rows = conn.execute(
802-
f"""
803-
SELECT skill_id, name, description, category, is_active,
804-
visibility, creator_id,
805-
lineage_origin, lineage_generation,
806-
total_selections, total_applied,
807-
total_completions, total_fallbacks,
808-
first_seen, last_updated
809-
FROM skill_records
810-
{where}
811-
ORDER BY last_updated DESC
812-
"""
813-
).fetchall()
814-
return [dict(r) for r in rows]
807+
return self._tag_search.get_summary(active_only=active_only)
815808

816809
@_db_retry()
817810
def get_stats(self, *, active_only: bool = True) -> Dict[str, Any]:
818-
"""Aggregate statistics across skills."""
819-
with self._reader() as conn:
820-
where = " WHERE is_active=1" if active_only else ""
821-
total = conn.execute(f"SELECT COUNT(*) FROM skill_records{where}").fetchone()[0]
822-
823-
by_category = {
824-
r["category"]: r["cnt"]
825-
for r in conn.execute(
826-
f"SELECT category, COUNT(*) AS cnt FROM skill_records{where} GROUP BY category"
827-
).fetchall()
828-
}
829-
by_origin = {
830-
r["lineage_origin"]: r["cnt"]
831-
for r in conn.execute(
832-
f"SELECT lineage_origin, COUNT(*) AS cnt FROM skill_records{where} GROUP BY lineage_origin"
833-
).fetchall()
834-
}
835-
# Delegate analysis stats to AnalysisStore (Epic 3.4)
836-
analysis_stats = self._analyses.get_analysis_stats()
837-
n_analyses = analysis_stats["total_analyses"]
838-
n_candidates = analysis_stats["evolution_candidates"]
839-
agg = conn.execute(
840-
f"""
841-
SELECT SUM(total_selections) AS sel,
842-
SUM(total_applied) AS app,
843-
SUM(total_completions) AS comp,
844-
SUM(total_fallbacks) AS fb
845-
FROM skill_records{where}
846-
"""
847-
).fetchone()
848-
849-
# Also report total (including inactive) for context
850-
total_all = conn.execute("SELECT COUNT(*) FROM skill_records").fetchone()[0]
851-
852-
return {
853-
"total_skills": total,
854-
"total_skills_all": total_all,
855-
"by_category": by_category,
856-
"by_origin": by_origin,
857-
"total_analyses": n_analyses,
858-
"evolution_candidates": n_candidates,
859-
"total_selections": agg["sel"] or 0,
860-
"total_applied": agg["app"] or 0,
861-
"total_completions": agg["comp"] or 0,
862-
"total_fallbacks": agg["fb"] or 0,
863-
}
811+
"""Aggregate statistics across skills.
812+
813+
Delegates to :class:`TagSearch` (Epic 3.5) for skill stats and
814+
:class:`AnalysisStore` (Epic 3.4) for analysis stats.
815+
"""
816+
# Get skill-related stats from TagSearch
817+
stats = self._tag_search.get_stats(active_only=active_only)
818+
819+
# Merge in analysis stats from AnalysisStore
820+
analysis_stats = self._analyses.get_analysis_stats()
821+
stats.update({
822+
"total_analyses": analysis_stats["total_analyses"],
823+
"evolution_candidates": analysis_stats["evolution_candidates"],
824+
})
825+
826+
return stats
864827

865828
@_db_retry()
866829
def get_task_skill_summary(self, task_id: str) -> Dict[str, Any]:
@@ -892,45 +855,20 @@ def get_top_skills(
892855
``applied_rate`` — applied / selections
893856
``completion_rate`` — completions / applied
894857
``total_selections``— raw count
858+
859+
Delegates to :class:`TagSearch` (Epic 3.5).
895860
"""
896-
rate_exprs = {
897-
"effective_rate": ("CAST(total_completions AS REAL) / total_selections"),
898-
"applied_rate": ("CAST(total_applied AS REAL) / total_selections"),
899-
"completion_rate": (
900-
"CASE WHEN total_applied > 0 THEN CAST(total_completions AS REAL) / total_applied ELSE 0.0 END"
901-
),
902-
"total_selections": "total_selections",
903-
}
904-
expr = rate_exprs.get(metric, rate_exprs["effective_rate"])
905-
active_clause = " AND is_active=1" if active_only else ""
906-
907-
with self._reader() as conn:
908-
rows = conn.execute(
909-
f"SELECT *, ({expr}) AS _rank "
910-
f"FROM skill_records "
911-
f"WHERE total_selections >= ?{active_clause} "
912-
f"ORDER BY _rank DESC LIMIT ?",
913-
(min_selections, n),
914-
).fetchall()
915-
results = []
916-
for r in rows:
917-
d = dict(r)
918-
d.pop("_rank", None)
919-
results.append(d)
920-
return results
861+
return self._tag_search.get_top_skills(
862+
n=n, metric=metric, min_selections=min_selections, active_only=active_only
863+
)
921864

922865
@_db_retry()
923866
def get_count_and_timestamp(self, *, active_only: bool = True) -> Dict[str, Any]:
924-
"""Skill count + newest ``last_updated`` for cheap change detection."""
925-
with self._reader() as conn:
926-
where = " WHERE is_active=1" if active_only else ""
927-
row = conn.execute(
928-
f"SELECT COUNT(*) AS cnt, MAX(last_updated) AS max_ts FROM skill_records{where}"
929-
).fetchone()
930-
return {
931-
"count": row["cnt"] if row else 0,
932-
"max_last_updated": row["max_ts"] if row else None,
933-
}
867+
"""Skill count + newest ``last_updated`` for cheap change detection.
868+
869+
Delegates to :class:`TagSearch` (Epic 3.5).
870+
"""
871+
return self._tag_search.get_count_and_timestamp(active_only=active_only)
934872

935873
# Lineage / Ancestry
936874
@_db_retry()
@@ -1073,16 +1011,8 @@ def _upsert(self, record: SkillRecord) -> None:
10731011
(record.skill_id, tk, 1 if tk in critical_set else 0),
10741012
)
10751013

1076-
# Sync tags
1077-
self._conn.execute(
1078-
"DELETE FROM skill_tags WHERE skill_id=?",
1079-
(record.skill_id,),
1080-
)
1081-
for tag in record.tags:
1082-
self._conn.execute(
1083-
"INSERT INTO skill_tags(skill_id, tag) VALUES(?,?)",
1084-
(record.skill_id, tag),
1085-
)
1014+
# Sync tags - delegate to TagSearch (Epic 3.5)
1015+
self._tag_search.sync_tags(record.skill_id, record.tags)
10861016

10871017
# Sync analyses (insert only NEW ones, dedup by task_id) - delegate to AnalysisStore
10881018
self._analyses.bulk_upsert_analyses(record.recent_analyses)
@@ -1130,7 +1060,8 @@ def _to_record(self, conn: sqlite3.Connection, row: sqlite3.Row) -> SkillRecord:
11301060
(sid,),
11311061
).fetchall()
11321062

1133-
tag_rows = conn.execute("SELECT tag FROM skill_tags WHERE skill_id=?", (sid,)).fetchall()
1063+
# Load tags - delegate to TagSearch (Epic 3.5)
1064+
tags = self._tag_search.get_tags(sid)
11341065

11351066
# Load recent analyses involving this skill - delegate to AnalysisStore (Epic 3.4)
11361067
recent_analyses = self._analyses.load_recent_analyses_for_skill(
@@ -1144,7 +1075,7 @@ def _to_record(self, conn: sqlite3.Connection, row: sqlite3.Row) -> SkillRecord:
11441075
path=row["path"],
11451076
is_active=bool(row["is_active"]),
11461077
category=SkillCategory(row["category"]),
1147-
tags=[r["tag"] for r in tag_rows],
1078+
tags=tags,
11481079
visibility=(SkillVisibility(row["visibility"]) if row["visibility"] else SkillVisibility.PRIVATE),
11491080
creator_id=row["creator_id"] or "",
11501081
lineage=lineage,
@@ -1158,3 +1089,52 @@ def _to_record(self, conn: sqlite3.Connection, row: sqlite3.Row) -> SkillRecord:
11581089
first_seen=datetime.fromisoformat(row["first_seen"]),
11591090
last_updated=datetime.fromisoformat(row["last_updated"]),
11601091
)
1092+
1093+
# ── Tag Search Facade Methods (Epic 3.5) ──────────────────────────────
1094+
# Fix Finding 3: Complete SkillStore facade for all extracted TagSearch methods
1095+
1096+
def find_skills_by_tags(
1097+
self,
1098+
tags: List[str],
1099+
*,
1100+
match_all: bool = False,
1101+
active_only: bool = True,
1102+
) -> List[str]:
1103+
"""Find skills by tags (facade to TagSearch)."""
1104+
return self._tag_search.find_skills_by_tags(
1105+
tags, match_all=match_all, active_only=active_only
1106+
)
1107+
1108+
def search_skills(
1109+
self,
1110+
query: Optional[str] = None,
1111+
*,
1112+
category: Optional[SkillCategory] = None,
1113+
visibility: Optional[SkillVisibility] = None,
1114+
tags: Optional[List[str]] = None,
1115+
match_all_tags: bool = False,
1116+
active_only: bool = True,
1117+
limit: Optional[int] = None,
1118+
) -> List[Dict[str, Any]]:
1119+
"""Search skills by multiple criteria (facade to TagSearch)."""
1120+
return self._tag_search.search_skills(
1121+
query,
1122+
category=category,
1123+
visibility=visibility,
1124+
tags=tags,
1125+
match_all_tags=match_all_tags,
1126+
active_only=active_only,
1127+
limit=limit,
1128+
)
1129+
1130+
def get_tags(self, skill_id: str) -> List[str]:
1131+
"""Get all tags for a skill (facade to TagSearch)."""
1132+
return self._tag_search.get_tags(skill_id)
1133+
1134+
def get_all_tags(self) -> List[Dict[str, Any]]:
1135+
"""Get all tags with usage counts (facade to TagSearch)."""
1136+
return self._tag_search.get_all_tags()
1137+
1138+
def sync_tags(self, skill_id: str, tags: List[str]) -> None:
1139+
"""Synchronize tags for a skill (facade to TagSearch)."""
1140+
return self._tag_search.sync_tags(skill_id, tags)

0 commit comments

Comments
 (0)