code-explore 0.2.0__tar.gz → 0.4.0__tar.gz

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 (51) hide show
  1. code_explore-0.4.0/.github/workflows/pages.yml +44 -0
  2. {code_explore-0.2.0 → code_explore-0.4.0}/PKG-INFO +1 -1
  3. {code_explore-0.2.0 → code_explore-0.4.0}/code_explore/api/main.py +93 -17
  4. {code_explore-0.2.0 → code_explore-0.4.0}/code_explore/cli/main.py +180 -17
  5. {code_explore-0.2.0 → code_explore-0.4.0}/code_explore/database.py +4 -1
  6. {code_explore-0.2.0 → code_explore-0.4.0}/code_explore/indexer/embeddings.py +5 -0
  7. {code_explore-0.2.0 → code_explore-0.4.0}/code_explore/models.py +25 -0
  8. code_explore-0.4.0/code_explore/search/filters.py +113 -0
  9. code_explore-0.4.0/code_explore/static/app.js +482 -0
  10. code_explore-0.4.0/code_explore/static/index.html +78 -0
  11. code_explore-0.4.0/code_explore/static/style.css +349 -0
  12. {code_explore-0.2.0 → code_explore-0.4.0}/code_explore/summarizer/ollama.py +21 -10
  13. code_explore-0.4.0/code_explore/tagger/__init__.py +56 -0
  14. code_explore-0.4.0/docs/CNAME +1 -0
  15. code_explore-0.4.0/docs/index.html +93 -0
  16. code_explore-0.4.0/install.cmd +58 -0
  17. code_explore-0.4.0/install.ps1 +84 -0
  18. code_explore-0.4.0/install.sh +110 -0
  19. {code_explore-0.2.0 → code_explore-0.4.0}/pyproject.toml +7 -1
  20. {code_explore-0.2.0 → code_explore-0.4.0}/.editorconfig +0 -0
  21. {code_explore-0.2.0 → code_explore-0.4.0}/.github/workflows/publish.yml +0 -0
  22. {code_explore-0.2.0 → code_explore-0.4.0}/.gitignore +0 -0
  23. {code_explore-0.2.0 → code_explore-0.4.0}/README.md +0 -0
  24. {code_explore-0.2.0 → code_explore-0.4.0}/code_explore/__init__.py +0 -0
  25. {code_explore-0.2.0 → code_explore-0.4.0}/code_explore/analyzer/__init__.py +0 -0
  26. {code_explore-0.2.0 → code_explore-0.4.0}/code_explore/analyzer/dependencies.py +0 -0
  27. {code_explore-0.2.0 → code_explore-0.4.0}/code_explore/analyzer/language.py +0 -0
  28. {code_explore-0.2.0 → code_explore-0.4.0}/code_explore/analyzer/metrics.py +0 -0
  29. {code_explore-0.2.0 → code_explore-0.4.0}/code_explore/analyzer/patterns.py +0 -0
  30. {code_explore-0.2.0 → code_explore-0.4.0}/code_explore/api/__init__.py +0 -0
  31. {code_explore-0.2.0 → code_explore-0.4.0}/code_explore/cli/__init__.py +0 -0
  32. {code_explore-0.2.0 → code_explore-0.4.0}/code_explore/cli/config_cmd.py +0 -0
  33. {code_explore-0.2.0 → code_explore-0.4.0}/code_explore/config.py +0 -0
  34. {code_explore-0.2.0 → code_explore-0.4.0}/code_explore/indexer/__init__.py +0 -0
  35. {code_explore-0.2.0 → code_explore-0.4.0}/code_explore/scanner/__init__.py +0 -0
  36. {code_explore-0.2.0 → code_explore-0.4.0}/code_explore/scanner/git_info.py +0 -0
  37. {code_explore-0.2.0 → code_explore-0.4.0}/code_explore/scanner/local.py +0 -0
  38. {code_explore-0.2.0 → code_explore-0.4.0}/code_explore/scanner/readme.py +0 -0
  39. {code_explore-0.2.0 → code_explore-0.4.0}/code_explore/search/__init__.py +0 -0
  40. {code_explore-0.2.0 → code_explore-0.4.0}/code_explore/search/fulltext.py +0 -0
  41. {code_explore-0.2.0 → code_explore-0.4.0}/code_explore/search/hybrid.py +0 -0
  42. {code_explore-0.2.0 → code_explore-0.4.0}/code_explore/search/semantic.py +0 -0
  43. {code_explore-0.2.0 → code_explore-0.4.0}/code_explore/summarizer/__init__.py +0 -0
  44. {code_explore-0.2.0 → code_explore-0.4.0}/tests/__init__.py +0 -0
  45. {code_explore-0.2.0 → code_explore-0.4.0}/tests/conftest.py +0 -0
  46. {code_explore-0.2.0 → code_explore-0.4.0}/tests/test_cli.py +0 -0
  47. {code_explore-0.2.0 → code_explore-0.4.0}/tests/test_config.py +0 -0
  48. {code_explore-0.2.0 → code_explore-0.4.0}/tests/test_config_cli.py +0 -0
  49. {code_explore-0.2.0 → code_explore-0.4.0}/tests/test_database.py +0 -0
  50. {code_explore-0.2.0 → code_explore-0.4.0}/tests/test_models.py +0 -0
  51. {code_explore-0.2.0 → code_explore-0.4.0}/tests/test_search_hybrid.py +0 -0
@@ -0,0 +1,44 @@
1
+ name: Deploy to GitHub Pages
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ paths:
7
+ - 'docs/**'
8
+ - 'install.*'
9
+
10
+ permissions:
11
+ contents: read
12
+ pages: write
13
+ id-token: write
14
+
15
+ concurrency:
16
+ group: pages
17
+ cancel-in-progress: true
18
+
19
+ jobs:
20
+ build:
21
+ runs-on: ubuntu-latest
22
+ steps:
23
+ - uses: actions/checkout@v4
24
+
25
+ - name: Prepare site
26
+ run: |
27
+ mkdir -p _site
28
+ cp docs/index.html _site/index.html
29
+ cp docs/CNAME _site/CNAME
30
+ cp install.sh _site/install.sh
31
+ cp install.ps1 _site/install.ps1
32
+ cp install.cmd _site/install.cmd
33
+
34
+ - uses: actions/upload-pages-artifact@v3
35
+
36
+ deploy:
37
+ needs: build
38
+ runs-on: ubuntu-latest
39
+ environment:
40
+ name: github-pages
41
+ url: ${{ steps.deployment.outputs.page_url }}
42
+ steps:
43
+ - id: deployment
44
+ uses: actions/deploy-pages@v4
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-explore
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Developer knowledge base CLI — scan, index, and search your programming projects
5
5
  Project-URL: Homepage, https://github.com/aipioneers/code-explore
6
6
  Project-URL: Repository, https://github.com/aipioneers/code-explore
@@ -1,18 +1,20 @@
1
1
  """FastAPI REST API for Code Explore."""
2
2
 
3
- import asyncio
4
3
  import hashlib
4
+ from collections import Counter
5
5
  from datetime import datetime
6
6
  from pathlib import Path
7
7
 
8
8
  from fastapi import FastAPI, HTTPException, Query
9
9
  from fastapi.middleware.cors import CORSMiddleware
10
+ from fastapi.staticfiles import StaticFiles
10
11
  from pydantic import BaseModel
11
12
 
12
13
  from code_explore.database import init_db, save_project, get_project, get_all_projects, get_project_count
13
- from code_explore.models import Project, ProjectSource, ProjectStatus
14
+ from code_explore.models import Project, ProjectSource, ProjectStatus, SearchFacets
15
+ from code_explore.search.filters import apply_filters, filter_projects, compute_facets
14
16
 
15
- app = FastAPI(title="Code Explore", version="0.1.0", description="Developer knowledge base API")
17
+ app = FastAPI(title="Code Explore", version="0.4.0", description="Developer knowledge base API")
16
18
 
17
19
  app.add_middleware(
18
20
  CORSMiddleware,
@@ -43,36 +45,38 @@ class StatsResponse(BaseModel):
43
45
  languages: dict[str, int]
44
46
  frameworks: dict[str, int]
45
47
  patterns: dict[str, int]
48
+ ai_tags: dict[str, int]
46
49
  total_files: int
47
50
  total_lines: int
48
51
  statuses: dict[str, int]
49
52
 
50
53
 
54
+ class TagInfo(BaseModel):
55
+ value: str
56
+ category: str
57
+ count: int
58
+
59
+
60
+ class TagsResponse(BaseModel):
61
+ tags: list[TagInfo]
62
+ total_tags: int
63
+ categories: dict[str, int]
64
+
65
+
51
66
  @app.get("/api/projects", response_model=list[Project])
52
67
  async def list_projects(
53
68
  language: str | None = Query(None, description="Filter by primary language"),
54
69
  framework: str | None = Query(None, description="Filter by framework"),
55
70
  source: str | None = Query(None, description="Filter by source (local, github, gitlab)"),
71
+ tag: str | None = Query(None, description="Filter by AI tag"),
56
72
  ):
57
73
  projects = get_all_projects()
58
74
 
59
- if language:
60
- lang_lower = language.lower()
61
- projects = [
62
- p for p in projects
63
- if p.primary_language and p.primary_language.lower() == lang_lower
64
- ]
65
-
66
- if framework:
67
- fw_lower = framework.lower()
68
- projects = [
69
- p for p in projects
70
- if any(f.lower() == fw_lower for f in p.frameworks)
71
- ]
72
-
73
75
  if source:
74
76
  projects = [p for p in projects if p.source.value == source]
75
77
 
78
+ projects = filter_projects(projects, language=language, framework=framework, tag=tag)
79
+
76
80
  return projects
77
81
 
78
82
 
@@ -89,6 +93,10 @@ async def search_projects(
89
93
  q: str = Query(..., description="Search query"),
90
94
  mode: str = Query("hybrid", description="Search mode: fulltext, semantic, or hybrid"),
91
95
  limit: int = Query(20, ge=1, le=100, description="Maximum results"),
96
+ language: str | None = Query(None, description="Filter by primary language"),
97
+ framework: str | None = Query(None, description="Filter by framework"),
98
+ pattern: str | None = Query(None, description="Filter by pattern"),
99
+ tag: str | None = Query(None, description="Filter by AI tag"),
92
100
  ):
93
101
  if mode == "fulltext":
94
102
  from code_explore.search.fulltext import search as fulltext_search
@@ -100,6 +108,9 @@ async def search_projects(
100
108
  from code_explore.search.hybrid import search as hybrid_search
101
109
  results = hybrid_search(q, limit=limit)
102
110
 
111
+ # Apply post-filters
112
+ results = apply_filters(results, language=language, framework=framework, pattern=pattern, tag=tag)
113
+
103
114
  return [
104
115
  {
105
116
  "project": r.project.model_dump(),
@@ -111,6 +122,61 @@ async def search_projects(
111
122
  ]
112
123
 
113
124
 
125
+ @app.get("/api/facets", response_model=SearchFacets)
126
+ async def get_facets(
127
+ q: str | None = Query(None, description="Search query to scope facets"),
128
+ language: str | None = Query(None, description="Active language filter"),
129
+ framework: str | None = Query(None, description="Active framework filter"),
130
+ pattern: str | None = Query(None, description="Active pattern filter"),
131
+ tag: str | None = Query(None, description="Active tag filter"),
132
+ ):
133
+ if q:
134
+ # Get search results first, then compute facets from those
135
+ from code_explore.search.hybrid import search as hybrid_search
136
+ results = hybrid_search(q, limit=500)
137
+ projects = [r.project for r in results]
138
+ else:
139
+ projects = get_all_projects()
140
+
141
+ # Apply active filters to scope facets
142
+ projects = filter_projects(projects, language=language, framework=framework, pattern=pattern, tag=tag)
143
+
144
+ return compute_facets(projects)
145
+
146
+
147
+ @app.get("/api/tags", response_model=TagsResponse)
148
+ async def get_tags(
149
+ category: str | None = Query(None, description="Filter by category: domain, technology-role, maturity"),
150
+ ):
151
+ projects = get_all_projects()
152
+
153
+ tag_counts: Counter[str] = Counter()
154
+ tag_categories: dict[str, str] = {}
155
+
156
+ for p in projects:
157
+ for t in p.ai_tags:
158
+ cat = t.category.value if hasattr(t.category, "value") else str(t.category)
159
+ if category and cat != category:
160
+ continue
161
+ tag_counts[t.value] += 1
162
+ tag_categories[t.value] = cat
163
+
164
+ tags_list = [
165
+ TagInfo(value=value, category=tag_categories[value], count=count)
166
+ for value, count in tag_counts.most_common()
167
+ ]
168
+
169
+ category_counts: Counter[str] = Counter()
170
+ for t in tags_list:
171
+ category_counts[t.category] += 1
172
+
173
+ return TagsResponse(
174
+ tags=tags_list,
175
+ total_tags=len(tags_list),
176
+ categories=dict(category_counts),
177
+ )
178
+
179
+
114
180
  @app.get("/api/stats", response_model=StatsResponse)
115
181
  async def get_stats():
116
182
  projects = get_all_projects()
@@ -118,6 +184,7 @@ async def get_stats():
118
184
  languages: dict[str, int] = {}
119
185
  frameworks: dict[str, int] = {}
120
186
  patterns: dict[str, int] = {}
187
+ ai_tags: dict[str, int] = {}
121
188
  statuses: dict[str, int] = {}
122
189
  total_files = 0
123
190
  total_lines = 0
@@ -129,6 +196,8 @@ async def get_stats():
129
196
  frameworks[fw] = frameworks.get(fw, 0) + 1
130
197
  for pat in p.patterns:
131
198
  patterns[pat.name] = patterns.get(pat.name, 0) + 1
199
+ for t in p.ai_tags:
200
+ ai_tags[t.value] = ai_tags.get(t.value, 0) + 1
132
201
  statuses[p.status.value] = statuses.get(p.status.value, 0) + 1
133
202
  total_files += p.quality.total_files
134
203
  total_lines += p.quality.total_lines
@@ -138,6 +207,7 @@ async def get_stats():
138
207
  languages=dict(sorted(languages.items(), key=lambda x: x[1], reverse=True)),
139
208
  frameworks=dict(sorted(frameworks.items(), key=lambda x: x[1], reverse=True)),
140
209
  patterns=dict(sorted(patterns.items(), key=lambda x: x[1], reverse=True)),
210
+ ai_tags=dict(sorted(ai_tags.items(), key=lambda x: x[1], reverse=True)),
141
211
  total_files=total_files,
142
212
  total_lines=total_lines,
143
213
  statuses=statuses,
@@ -195,3 +265,9 @@ async def trigger_scan(request: ScanRequest):
195
265
  results.append(project)
196
266
 
197
267
  return ScanResponse(scanned=len(results), projects=results)
268
+
269
+
270
+ # Mount static files for the dashboard (must be after API routes)
271
+ _static_dir = Path(__file__).parent.parent / "static"
272
+ if _static_dir.is_dir():
273
+ app.mount("/", StaticFiles(directory=str(_static_dir), html=True), name="static")
@@ -34,8 +34,11 @@ def scan(
34
34
  path: str = typer.Argument(..., help="Root directory to scan for repositories"),
35
35
  depth: int = typer.Option(4, "--depth", "-d", help="Maximum directory depth"),
36
36
  force: bool = typer.Option(False, "--force", "-f", help="Re-scan existing projects"),
37
+ no_ai: bool = typer.Option(False, "--no-ai", help="Skip AI summaries and tags (requires Ollama)"),
38
+ no_embed: bool = typer.Option(False, "--no-embed", help="Skip vector embeddings"),
39
+ model: str | None = typer.Option(None, "--model", "-m", help="Ollama model name"),
37
40
  ) -> None:
38
- """Scan a local directory for git repositories and analyze them."""
41
+ """Scan repositories, analyze, summarize, and index all in one step."""
39
42
  from code_explore.scanner.local import scan_local_repos
40
43
  from code_explore.scanner.git_info import extract_git_info, get_git_head
41
44
  from code_explore.scanner.readme import read_readme, list_key_files
@@ -51,7 +54,16 @@ def scan(
51
54
  console.print(f"[red]Error:[/red] Path does not exist: {root}")
52
55
  raise typer.Exit(1)
53
56
 
54
- console.print(Panel(f"Scanning [bold cyan]{root}[/bold cyan] (depth={depth})", title="Code Explore"))
57
+ steps = ["scan", "analyze"]
58
+ if not no_ai:
59
+ steps.append("summarize + tag")
60
+ if not no_embed:
61
+ steps.append("embed")
62
+ console.print(Panel(
63
+ f"Scanning [bold cyan]{root}[/bold cyan] (depth={depth})\n"
64
+ f"Steps: {' → '.join(steps)}",
65
+ title="Code Explore",
66
+ ))
55
67
 
56
68
  repos = asyncio.run(scan_local_repos(root, max_depth=depth))
57
69
 
@@ -60,6 +72,7 @@ def scan(
60
72
  raise typer.Exit(0)
61
73
 
62
74
  results: list[Project] = []
75
+ new_or_changed: list[Project] = []
63
76
 
64
77
  with Progress(
65
78
  SpinnerColumn(),
@@ -68,6 +81,7 @@ def scan(
68
81
  MofNCompleteColumn(),
69
82
  console=console,
70
83
  ) as progress:
84
+ # Phase 1: Scan & analyze
71
85
  task = progress.add_task("Analyzing repositories...", total=len(repos))
72
86
 
73
87
  for repo_path in repos:
@@ -117,8 +131,55 @@ def scan(
117
131
 
118
132
  save_project(project)
119
133
  results.append(project)
134
+ new_or_changed.append(project)
120
135
  progress.update(task, advance=1)
121
136
 
137
+ # Phase 2: AI summaries & tags (all projects that need them)
138
+ if not no_ai:
139
+ from code_explore.summarizer.ollama import summarize_project
140
+
141
+ ai_candidates = [p for p in results if not p.summary or not p.ai_tags]
142
+ if ai_candidates:
143
+ ai_task = progress.add_task("AI summaries & tags...", total=len(ai_candidates))
144
+ summarized = 0
145
+ tagged = 0
146
+
147
+ for project in ai_candidates:
148
+ progress.update(ai_task, description=f"Summarizing [cyan]{project.name}[/cyan]")
149
+ needs_summary = not project.summary
150
+ needs_tags = not project.ai_tags
151
+
152
+ if model:
153
+ summary, tags, concepts, ai_tags = summarize_project(project, model=model)
154
+ else:
155
+ summary, tags, concepts, ai_tags = summarize_project(project)
156
+
157
+ if summary and needs_summary:
158
+ project.summary = summary
159
+ project.tags = tags
160
+ project.concepts = concepts
161
+ summarized += 1
162
+ if ai_tags and needs_tags:
163
+ project.ai_tags = ai_tags
164
+ tagged += 1
165
+ if summary or ai_tags:
166
+ save_project(project)
167
+ progress.update(ai_task, advance=1)
168
+
169
+ # Phase 3: Embeddings (all projects)
170
+ if not no_embed:
171
+ from code_explore.indexer.embeddings import index_project as embed_project
172
+ from datetime import datetime
173
+
174
+ embed_task = progress.add_task("Generating embeddings...", total=len(results))
175
+ for project in results:
176
+ progress.update(embed_task, description=f"Embedding [cyan]{project.name}[/cyan]")
177
+ embed_project(project)
178
+ project.status = ProjectStatus.INDEXED
179
+ project.indexed_at = datetime.now()
180
+ save_project(project)
181
+ progress.update(embed_task, advance=1)
182
+
122
183
  table = Table(title=f"Scanned {len(results)} Projects")
123
184
  table.add_column("Name", style="cyan", no_wrap=True)
124
185
  table.add_column("Language", style="green")
@@ -145,9 +206,14 @@ def search(
145
206
  query: str = typer.Argument(..., help="Search query"),
146
207
  mode: str = typer.Option("hybrid", "--mode", "-m", help="Search mode: fulltext, semantic, or hybrid"),
147
208
  limit: int = typer.Option(None, "--limit", "-l", help="Maximum results"),
209
+ language: str = typer.Option(None, "--language", "-L", help="Filter by primary language"),
210
+ framework: str = typer.Option(None, "--framework", "-F", help="Filter by framework"),
211
+ pattern: str = typer.Option(None, "--pattern", help="Filter by architectural pattern"),
212
+ tag: str = typer.Option(None, "--tag", "-t", help="Filter by AI-generated tag"),
148
213
  ) -> None:
149
214
  """Search across all indexed projects."""
150
215
  from code_explore.config import get_config
216
+ from code_explore.search.filters import apply_filters
151
217
 
152
218
  if limit is None:
153
219
  limit = get_config().result_limit
@@ -163,6 +229,23 @@ def search(
163
229
  from code_explore.search.hybrid import search as hybrid_search
164
230
  results = hybrid_search(query, limit=limit)
165
231
 
232
+ # Apply post-filters
233
+ results = apply_filters(results, language=language, framework=framework, pattern=pattern, tag=tag)
234
+
235
+ # Show active filters
236
+ active_filters = []
237
+ if language:
238
+ active_filters.append(f"language={language}")
239
+ if framework:
240
+ active_filters.append(f"framework={framework}")
241
+ if pattern:
242
+ active_filters.append(f"pattern={pattern}")
243
+ if tag:
244
+ active_filters.append(f"tag={tag}")
245
+
246
+ if active_filters:
247
+ console.print(f"[dim]Filters: {', '.join(active_filters)}[/dim]")
248
+
166
249
  if not results:
167
250
  console.print(f"[yellow]No results found for:[/yellow] {query}")
168
251
  raise typer.Exit(0)
@@ -282,6 +365,16 @@ def show(
282
365
  if project.concepts:
283
366
  tree.add(f"[bold]Concepts:[/bold] {', '.join(project.concepts)}")
284
367
 
368
+ # AI Tags grouped by category
369
+ if project.ai_tags:
370
+ ai_branch = tree.add("[bold]AI Tags[/bold]")
371
+ by_category: dict[str, list[str]] = {}
372
+ for t in project.ai_tags:
373
+ cat = t.category.value if hasattr(t.category, "value") else str(t.category)
374
+ by_category.setdefault(cat, []).append(t.value)
375
+ for cat, values in sorted(by_category.items()):
376
+ ai_branch.add(f"[magenta]{cat}:[/magenta] {', '.join(values)}")
377
+
285
378
  console.print(Panel(tree, title=f"Project: {project.name}", border_style="cyan"))
286
379
 
287
380
 
@@ -289,7 +382,7 @@ def show(
289
382
  def index(
290
383
  model: str | None = typer.Option(None, "--model", "-m", help="Ollama model name for summarization"),
291
384
  ) -> None:
292
- """Generate embeddings and AI summaries for all projects."""
385
+ """Re-generate AI summaries and embeddings for existing projects."""
293
386
  from code_explore.indexer.embeddings import index_project as embed_project, index_all_projects
294
387
  from code_explore.summarizer.ollama import summarize_project
295
388
 
@@ -309,22 +402,30 @@ def index(
309
402
  MofNCompleteColumn(),
310
403
  console=console,
311
404
  ) as progress:
312
- summary_task = progress.add_task("Generating summaries...", total=len(projects))
405
+ summary_task = progress.add_task("Generating summaries & AI tags...", total=len(projects))
313
406
  summarized = 0
407
+ tagged = 0
314
408
 
315
409
  for project in projects:
316
- if not project.summary:
410
+ needs_summary = not project.summary
411
+ needs_tags = not project.ai_tags
412
+
413
+ if needs_summary or needs_tags:
317
414
  progress.update(summary_task, description=f"Summarizing [cyan]{project.name}[/cyan]")
318
415
  if model:
319
- summary, tags, concepts = summarize_project(project, model=model)
416
+ summary, tags, concepts, ai_tags = summarize_project(project, model=model)
320
417
  else:
321
- summary, tags, concepts = summarize_project(project)
322
- if summary:
418
+ summary, tags, concepts, ai_tags = summarize_project(project)
419
+ if summary and needs_summary:
323
420
  project.summary = summary
324
421
  project.tags = tags
325
422
  project.concepts = concepts
326
- save_project(project)
327
423
  summarized += 1
424
+ if ai_tags:
425
+ project.ai_tags = ai_tags
426
+ tagged += 1
427
+ if summary or ai_tags:
428
+ save_project(project)
328
429
  progress.update(summary_task, advance=1)
329
430
 
330
431
  embed_task = progress.add_task("Generating embeddings...", total=len(projects))
@@ -340,7 +441,62 @@ def index(
340
441
  indexed += 1
341
442
  progress.update(embed_task, advance=1)
342
443
 
343
- console.print(f"[green]Summarized {summarized} projects, indexed {indexed} projects.[/green]")
444
+ console.print(f"[green]Summarized {summarized}, AI-tagged {tagged}, indexed {indexed} projects.[/green]")
445
+
446
+
447
+ @app.command()
448
+ def tags(
449
+ category: str = typer.Option(None, "--category", "-c", help="Filter by category: domain, technology-role, maturity"),
450
+ ) -> None:
451
+ """List all unique AI tags across projects with counts."""
452
+ init_db()
453
+ projects = get_all_projects()
454
+
455
+ if not projects:
456
+ console.print("[yellow]No projects found. Run 'scan' first.[/yellow]")
457
+ raise typer.Exit(0)
458
+
459
+ # Collect all AI tags with counts
460
+ tag_counts: Counter[str] = Counter()
461
+ tag_categories: dict[str, str] = {}
462
+
463
+ for p in projects:
464
+ for t in p.ai_tags:
465
+ cat = t.category.value if hasattr(t.category, "value") else str(t.category)
466
+ if category and cat != category:
467
+ continue
468
+ tag_counts[t.value] += 1
469
+ tag_categories[t.value] = cat
470
+
471
+ if not tag_counts:
472
+ if category:
473
+ console.print(f"[yellow]No AI tags found for category '{category}'.[/yellow]")
474
+ else:
475
+ console.print("[yellow]No AI tags found. Run 'cex index' to generate tags.[/yellow]")
476
+ raise typer.Exit(0)
477
+
478
+ # Group by category
479
+ by_category: dict[str, list[tuple[str, int]]] = {}
480
+ for tag_value, count in tag_counts.most_common():
481
+ cat = tag_categories[tag_value]
482
+ by_category.setdefault(cat, []).append((tag_value, count))
483
+
484
+ total_tags = len(tag_counts)
485
+ total_projects = len(projects)
486
+ console.print(Panel(
487
+ f"[bold]{total_tags}[/bold] unique AI tags across [bold]{total_projects}[/bold] projects",
488
+ title="AI Tags",
489
+ border_style="magenta",
490
+ ))
491
+
492
+ for cat in sorted(by_category.keys()):
493
+ items = by_category[cat]
494
+ table = Table(title=f"{cat} tags")
495
+ table.add_column("Tag", style="magenta")
496
+ table.add_column("Projects", justify="right", style="yellow")
497
+ for tag_value, count in items:
498
+ table.add_row(tag_value, str(count))
499
+ console.print(table)
344
500
 
345
501
 
346
502
  @app.command()
@@ -348,6 +504,7 @@ def update(
348
504
  force: bool = typer.Option(False, "--force", "-f", help="Re-analyze even if git HEAD unchanged"),
349
505
  reindex: bool = typer.Option(False, "--reindex", help="Regenerate embeddings for updated projects"),
350
506
  resummarize: bool = typer.Option(False, "--resummarize", help="Regenerate AI summaries for updated projects"),
507
+ retag: bool = typer.Option(False, "--retag", help="Regenerate AI tags for updated projects"),
351
508
  ) -> None:
352
509
  """Update existing projects by re-analyzing changed repositories."""
353
510
  from datetime import datetime
@@ -433,20 +590,25 @@ def update(
433
590
  updated += 1
434
591
  progress.update(task, advance=1)
435
592
 
436
- if resummarize and updated_projects:
593
+ if (resummarize or retag) and updated_projects:
437
594
  from code_explore.summarizer.ollama import summarize_project
438
595
 
439
596
  summary_task = progress.add_task("Resummarizing...", total=len(updated_projects))
440
597
  for project in updated_projects:
441
598
  progress.update(summary_task, description=f"Summarizing [cyan]{project.name}[/cyan]")
442
- project.summary = None
443
- project.tags = []
444
- project.concepts = []
445
- summary, tags, concepts = summarize_project(project)
446
- if summary:
599
+ if resummarize:
600
+ project.summary = None
601
+ project.tags = []
602
+ project.concepts = []
603
+ if retag:
604
+ project.ai_tags = []
605
+ summary, tags, concepts, ai_tags = summarize_project(project)
606
+ if summary and resummarize:
447
607
  project.summary = summary
448
608
  project.tags = tags
449
609
  project.concepts = concepts
610
+ if ai_tags and retag:
611
+ project.ai_tags = ai_tags
450
612
  save_project(project)
451
613
  progress.update(summary_task, advance=1)
452
614
 
@@ -547,12 +709,13 @@ def serve(
547
709
  host: str = typer.Option("0.0.0.0", "--host", "-h", help="Bind host"),
548
710
  port: int = typer.Option(8000, "--port", "-p", help="Bind port"),
549
711
  ) -> None:
550
- """Start the FastAPI server."""
712
+ """Start the FastAPI server with web dashboard."""
551
713
  import uvicorn
552
714
 
553
715
  init_db()
554
716
  console.print(Panel(
555
717
  f"Starting server on [bold cyan]http://{host}:{port}[/bold cyan]\n"
718
+ f"Dashboard at [bold cyan]http://{host}:{port}[/bold cyan]\n"
556
719
  f"API docs at [bold cyan]http://{host}:{port}/docs[/bold cyan]",
557
720
  title="Code Explore API",
558
721
  ))
@@ -110,7 +110,10 @@ def init_db(db_path: Path | None = None) -> None:
110
110
  def save_project(project: Project, db_path: Path | None = None) -> None:
111
111
  conn = get_connection(db_path)
112
112
  now = datetime.now().isoformat()
113
- tags_str = ", ".join(project.tags) if project.tags else ""
113
+ tag_parts = list(project.tags) if project.tags else []
114
+ if project.ai_tags:
115
+ tag_parts.extend(t.value for t in project.ai_tags)
116
+ tags_str = ", ".join(tag_parts)
114
117
 
115
118
  readme_str = (project.readme_snippet or "")[:2000]
116
119
 
@@ -93,6 +93,11 @@ def _project_to_text(project: Project) -> str:
93
93
  if project.concepts:
94
94
  parts.append(f"Concepts: {', '.join(project.concepts)}")
95
95
 
96
+ # AI classification tags
97
+ if project.ai_tags:
98
+ ai_tag_values = [t.value for t in project.ai_tags]
99
+ parts.append(f"AI Tags: {', '.join(ai_tag_values)}")
100
+
96
101
  # Language names
97
102
  languages = [lang.name for lang in project.languages]
98
103
  if languages:
@@ -20,6 +20,18 @@ class ProjectStatus(str, Enum):
20
20
  ERROR = "error"
21
21
 
22
22
 
23
+ class TagCategory(str, Enum):
24
+ DOMAIN = "domain"
25
+ TECHNOLOGY_ROLE = "technology-role"
26
+ MATURITY = "maturity"
27
+
28
+
29
+ class AiTag(BaseModel):
30
+ value: str
31
+ category: TagCategory = TagCategory.DOMAIN
32
+ confidence: float = 0.8
33
+
34
+
23
35
  class LanguageInfo(BaseModel):
24
36
  name: str
25
37
  files: int = 0
@@ -82,6 +94,7 @@ class Project(BaseModel):
82
94
  summary: str | None = None
83
95
  tags: list[str] = Field(default_factory=list)
84
96
  concepts: list[str] = Field(default_factory=list)
97
+ ai_tags: list[AiTag] = Field(default_factory=list)
85
98
 
86
99
  readme_snippet: str | None = None
87
100
  key_files: list[str] = Field(default_factory=list)
@@ -104,3 +117,15 @@ class SearchQuery(BaseModel):
104
117
  mode: str = "hybrid"
105
118
  limit: int = 20
106
119
  filters: dict = Field(default_factory=dict)
120
+ language: str | None = None
121
+ framework: str | None = None
122
+ pattern: str | None = None
123
+ tag: str | None = None
124
+
125
+
126
+ class SearchFacets(BaseModel):
127
+ languages: dict[str, int] = Field(default_factory=dict)
128
+ frameworks: dict[str, int] = Field(default_factory=dict)
129
+ patterns: dict[str, int] = Field(default_factory=dict)
130
+ tags: dict[str, int] = Field(default_factory=dict)
131
+ total: int = 0