code-explore 0.2.0__tar.gz → 0.3.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.3.0/.github/workflows/pages.yml +44 -0
  2. {code_explore-0.2.0 → code_explore-0.3.0}/PKG-INFO +1 -1
  3. {code_explore-0.2.0 → code_explore-0.3.0}/code_explore/api/main.py +93 -17
  4. {code_explore-0.2.0 → code_explore-0.3.0}/code_explore/cli/main.py +116 -14
  5. {code_explore-0.2.0 → code_explore-0.3.0}/code_explore/database.py +4 -1
  6. {code_explore-0.2.0 → code_explore-0.3.0}/code_explore/indexer/embeddings.py +5 -0
  7. {code_explore-0.2.0 → code_explore-0.3.0}/code_explore/models.py +25 -0
  8. code_explore-0.3.0/code_explore/search/filters.py +113 -0
  9. code_explore-0.3.0/code_explore/static/app.js +482 -0
  10. code_explore-0.3.0/code_explore/static/index.html +78 -0
  11. code_explore-0.3.0/code_explore/static/style.css +349 -0
  12. {code_explore-0.2.0 → code_explore-0.3.0}/code_explore/summarizer/ollama.py +21 -10
  13. code_explore-0.3.0/code_explore/tagger/__init__.py +56 -0
  14. code_explore-0.3.0/docs/CNAME +1 -0
  15. code_explore-0.3.0/docs/index.html +93 -0
  16. code_explore-0.3.0/install.cmd +58 -0
  17. code_explore-0.3.0/install.ps1 +84 -0
  18. code_explore-0.3.0/install.sh +110 -0
  19. {code_explore-0.2.0 → code_explore-0.3.0}/pyproject.toml +7 -1
  20. {code_explore-0.2.0 → code_explore-0.3.0}/.editorconfig +0 -0
  21. {code_explore-0.2.0 → code_explore-0.3.0}/.github/workflows/publish.yml +0 -0
  22. {code_explore-0.2.0 → code_explore-0.3.0}/.gitignore +0 -0
  23. {code_explore-0.2.0 → code_explore-0.3.0}/README.md +0 -0
  24. {code_explore-0.2.0 → code_explore-0.3.0}/code_explore/__init__.py +0 -0
  25. {code_explore-0.2.0 → code_explore-0.3.0}/code_explore/analyzer/__init__.py +0 -0
  26. {code_explore-0.2.0 → code_explore-0.3.0}/code_explore/analyzer/dependencies.py +0 -0
  27. {code_explore-0.2.0 → code_explore-0.3.0}/code_explore/analyzer/language.py +0 -0
  28. {code_explore-0.2.0 → code_explore-0.3.0}/code_explore/analyzer/metrics.py +0 -0
  29. {code_explore-0.2.0 → code_explore-0.3.0}/code_explore/analyzer/patterns.py +0 -0
  30. {code_explore-0.2.0 → code_explore-0.3.0}/code_explore/api/__init__.py +0 -0
  31. {code_explore-0.2.0 → code_explore-0.3.0}/code_explore/cli/__init__.py +0 -0
  32. {code_explore-0.2.0 → code_explore-0.3.0}/code_explore/cli/config_cmd.py +0 -0
  33. {code_explore-0.2.0 → code_explore-0.3.0}/code_explore/config.py +0 -0
  34. {code_explore-0.2.0 → code_explore-0.3.0}/code_explore/indexer/__init__.py +0 -0
  35. {code_explore-0.2.0 → code_explore-0.3.0}/code_explore/scanner/__init__.py +0 -0
  36. {code_explore-0.2.0 → code_explore-0.3.0}/code_explore/scanner/git_info.py +0 -0
  37. {code_explore-0.2.0 → code_explore-0.3.0}/code_explore/scanner/local.py +0 -0
  38. {code_explore-0.2.0 → code_explore-0.3.0}/code_explore/scanner/readme.py +0 -0
  39. {code_explore-0.2.0 → code_explore-0.3.0}/code_explore/search/__init__.py +0 -0
  40. {code_explore-0.2.0 → code_explore-0.3.0}/code_explore/search/fulltext.py +0 -0
  41. {code_explore-0.2.0 → code_explore-0.3.0}/code_explore/search/hybrid.py +0 -0
  42. {code_explore-0.2.0 → code_explore-0.3.0}/code_explore/search/semantic.py +0 -0
  43. {code_explore-0.2.0 → code_explore-0.3.0}/code_explore/summarizer/__init__.py +0 -0
  44. {code_explore-0.2.0 → code_explore-0.3.0}/tests/__init__.py +0 -0
  45. {code_explore-0.2.0 → code_explore-0.3.0}/tests/conftest.py +0 -0
  46. {code_explore-0.2.0 → code_explore-0.3.0}/tests/test_cli.py +0 -0
  47. {code_explore-0.2.0 → code_explore-0.3.0}/tests/test_config.py +0 -0
  48. {code_explore-0.2.0 → code_explore-0.3.0}/tests/test_config_cli.py +0 -0
  49. {code_explore-0.2.0 → code_explore-0.3.0}/tests/test_database.py +0 -0
  50. {code_explore-0.2.0 → code_explore-0.3.0}/tests/test_models.py +0 -0
  51. {code_explore-0.2.0 → code_explore-0.3.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.3.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.3.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")
@@ -145,9 +145,14 @@ def search(
145
145
  query: str = typer.Argument(..., help="Search query"),
146
146
  mode: str = typer.Option("hybrid", "--mode", "-m", help="Search mode: fulltext, semantic, or hybrid"),
147
147
  limit: int = typer.Option(None, "--limit", "-l", help="Maximum results"),
148
+ language: str = typer.Option(None, "--language", "-L", help="Filter by primary language"),
149
+ framework: str = typer.Option(None, "--framework", "-F", help="Filter by framework"),
150
+ pattern: str = typer.Option(None, "--pattern", help="Filter by architectural pattern"),
151
+ tag: str = typer.Option(None, "--tag", "-t", help="Filter by AI-generated tag"),
148
152
  ) -> None:
149
153
  """Search across all indexed projects."""
150
154
  from code_explore.config import get_config
155
+ from code_explore.search.filters import apply_filters
151
156
 
152
157
  if limit is None:
153
158
  limit = get_config().result_limit
@@ -163,6 +168,23 @@ def search(
163
168
  from code_explore.search.hybrid import search as hybrid_search
164
169
  results = hybrid_search(query, limit=limit)
165
170
 
171
+ # Apply post-filters
172
+ results = apply_filters(results, language=language, framework=framework, pattern=pattern, tag=tag)
173
+
174
+ # Show active filters
175
+ active_filters = []
176
+ if language:
177
+ active_filters.append(f"language={language}")
178
+ if framework:
179
+ active_filters.append(f"framework={framework}")
180
+ if pattern:
181
+ active_filters.append(f"pattern={pattern}")
182
+ if tag:
183
+ active_filters.append(f"tag={tag}")
184
+
185
+ if active_filters:
186
+ console.print(f"[dim]Filters: {', '.join(active_filters)}[/dim]")
187
+
166
188
  if not results:
167
189
  console.print(f"[yellow]No results found for:[/yellow] {query}")
168
190
  raise typer.Exit(0)
@@ -282,6 +304,16 @@ def show(
282
304
  if project.concepts:
283
305
  tree.add(f"[bold]Concepts:[/bold] {', '.join(project.concepts)}")
284
306
 
307
+ # AI Tags grouped by category
308
+ if project.ai_tags:
309
+ ai_branch = tree.add("[bold]AI Tags[/bold]")
310
+ by_category: dict[str, list[str]] = {}
311
+ for t in project.ai_tags:
312
+ cat = t.category.value if hasattr(t.category, "value") else str(t.category)
313
+ by_category.setdefault(cat, []).append(t.value)
314
+ for cat, values in sorted(by_category.items()):
315
+ ai_branch.add(f"[magenta]{cat}:[/magenta] {', '.join(values)}")
316
+
285
317
  console.print(Panel(tree, title=f"Project: {project.name}", border_style="cyan"))
286
318
 
287
319
 
@@ -309,22 +341,30 @@ def index(
309
341
  MofNCompleteColumn(),
310
342
  console=console,
311
343
  ) as progress:
312
- summary_task = progress.add_task("Generating summaries...", total=len(projects))
344
+ summary_task = progress.add_task("Generating summaries & AI tags...", total=len(projects))
313
345
  summarized = 0
346
+ tagged = 0
314
347
 
315
348
  for project in projects:
316
- if not project.summary:
349
+ needs_summary = not project.summary
350
+ needs_tags = not project.ai_tags
351
+
352
+ if needs_summary or needs_tags:
317
353
  progress.update(summary_task, description=f"Summarizing [cyan]{project.name}[/cyan]")
318
354
  if model:
319
- summary, tags, concepts = summarize_project(project, model=model)
355
+ summary, tags, concepts, ai_tags = summarize_project(project, model=model)
320
356
  else:
321
- summary, tags, concepts = summarize_project(project)
322
- if summary:
357
+ summary, tags, concepts, ai_tags = summarize_project(project)
358
+ if summary and needs_summary:
323
359
  project.summary = summary
324
360
  project.tags = tags
325
361
  project.concepts = concepts
326
- save_project(project)
327
362
  summarized += 1
363
+ if ai_tags:
364
+ project.ai_tags = ai_tags
365
+ tagged += 1
366
+ if summary or ai_tags:
367
+ save_project(project)
328
368
  progress.update(summary_task, advance=1)
329
369
 
330
370
  embed_task = progress.add_task("Generating embeddings...", total=len(projects))
@@ -340,7 +380,62 @@ def index(
340
380
  indexed += 1
341
381
  progress.update(embed_task, advance=1)
342
382
 
343
- console.print(f"[green]Summarized {summarized} projects, indexed {indexed} projects.[/green]")
383
+ console.print(f"[green]Summarized {summarized}, AI-tagged {tagged}, indexed {indexed} projects.[/green]")
384
+
385
+
386
+ @app.command()
387
+ def tags(
388
+ category: str = typer.Option(None, "--category", "-c", help="Filter by category: domain, technology-role, maturity"),
389
+ ) -> None:
390
+ """List all unique AI tags across projects with counts."""
391
+ init_db()
392
+ projects = get_all_projects()
393
+
394
+ if not projects:
395
+ console.print("[yellow]No projects found. Run 'scan' first.[/yellow]")
396
+ raise typer.Exit(0)
397
+
398
+ # Collect all AI tags with counts
399
+ tag_counts: Counter[str] = Counter()
400
+ tag_categories: dict[str, str] = {}
401
+
402
+ for p in projects:
403
+ for t in p.ai_tags:
404
+ cat = t.category.value if hasattr(t.category, "value") else str(t.category)
405
+ if category and cat != category:
406
+ continue
407
+ tag_counts[t.value] += 1
408
+ tag_categories[t.value] = cat
409
+
410
+ if not tag_counts:
411
+ if category:
412
+ console.print(f"[yellow]No AI tags found for category '{category}'.[/yellow]")
413
+ else:
414
+ console.print("[yellow]No AI tags found. Run 'cex index' to generate tags.[/yellow]")
415
+ raise typer.Exit(0)
416
+
417
+ # Group by category
418
+ by_category: dict[str, list[tuple[str, int]]] = {}
419
+ for tag_value, count in tag_counts.most_common():
420
+ cat = tag_categories[tag_value]
421
+ by_category.setdefault(cat, []).append((tag_value, count))
422
+
423
+ total_tags = len(tag_counts)
424
+ total_projects = len(projects)
425
+ console.print(Panel(
426
+ f"[bold]{total_tags}[/bold] unique AI tags across [bold]{total_projects}[/bold] projects",
427
+ title="AI Tags",
428
+ border_style="magenta",
429
+ ))
430
+
431
+ for cat in sorted(by_category.keys()):
432
+ items = by_category[cat]
433
+ table = Table(title=f"{cat} tags")
434
+ table.add_column("Tag", style="magenta")
435
+ table.add_column("Projects", justify="right", style="yellow")
436
+ for tag_value, count in items:
437
+ table.add_row(tag_value, str(count))
438
+ console.print(table)
344
439
 
345
440
 
346
441
  @app.command()
@@ -348,6 +443,7 @@ def update(
348
443
  force: bool = typer.Option(False, "--force", "-f", help="Re-analyze even if git HEAD unchanged"),
349
444
  reindex: bool = typer.Option(False, "--reindex", help="Regenerate embeddings for updated projects"),
350
445
  resummarize: bool = typer.Option(False, "--resummarize", help="Regenerate AI summaries for updated projects"),
446
+ retag: bool = typer.Option(False, "--retag", help="Regenerate AI tags for updated projects"),
351
447
  ) -> None:
352
448
  """Update existing projects by re-analyzing changed repositories."""
353
449
  from datetime import datetime
@@ -433,20 +529,25 @@ def update(
433
529
  updated += 1
434
530
  progress.update(task, advance=1)
435
531
 
436
- if resummarize and updated_projects:
532
+ if (resummarize or retag) and updated_projects:
437
533
  from code_explore.summarizer.ollama import summarize_project
438
534
 
439
535
  summary_task = progress.add_task("Resummarizing...", total=len(updated_projects))
440
536
  for project in updated_projects:
441
537
  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:
538
+ if resummarize:
539
+ project.summary = None
540
+ project.tags = []
541
+ project.concepts = []
542
+ if retag:
543
+ project.ai_tags = []
544
+ summary, tags, concepts, ai_tags = summarize_project(project)
545
+ if summary and resummarize:
447
546
  project.summary = summary
448
547
  project.tags = tags
449
548
  project.concepts = concepts
549
+ if ai_tags and retag:
550
+ project.ai_tags = ai_tags
450
551
  save_project(project)
451
552
  progress.update(summary_task, advance=1)
452
553
 
@@ -547,12 +648,13 @@ def serve(
547
648
  host: str = typer.Option("0.0.0.0", "--host", "-h", help="Bind host"),
548
649
  port: int = typer.Option(8000, "--port", "-p", help="Bind port"),
549
650
  ) -> None:
550
- """Start the FastAPI server."""
651
+ """Start the FastAPI server with web dashboard."""
551
652
  import uvicorn
552
653
 
553
654
  init_db()
554
655
  console.print(Panel(
555
656
  f"Starting server on [bold cyan]http://{host}:{port}[/bold cyan]\n"
657
+ f"Dashboard at [bold cyan]http://{host}:{port}[/bold cyan]\n"
556
658
  f"API docs at [bold cyan]http://{host}:{port}/docs[/bold cyan]",
557
659
  title="Code Explore API",
558
660
  ))
@@ -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
@@ -0,0 +1,113 @@
1
+ """Shared filter logic for faceted search."""
2
+
3
+ from collections import Counter
4
+
5
+ from code_explore.models import Project, SearchFacets, SearchResult
6
+
7
+
8
+ def apply_filters(
9
+ results: list[SearchResult],
10
+ language: str | None = None,
11
+ framework: str | None = None,
12
+ pattern: str | None = None,
13
+ tag: str | None = None,
14
+ ) -> list[SearchResult]:
15
+ """Apply post-filters to search results. Multiple filters combine with AND logic."""
16
+ filtered = results
17
+
18
+ if language:
19
+ lang_lower = language.lower()
20
+ filtered = [
21
+ r for r in filtered
22
+ if r.project.primary_language and r.project.primary_language.lower() == lang_lower
23
+ ]
24
+
25
+ if framework:
26
+ fw_lower = framework.lower()
27
+ filtered = [
28
+ r for r in filtered
29
+ if any(f.lower() == fw_lower for f in r.project.frameworks)
30
+ ]
31
+
32
+ if pattern:
33
+ pat_lower = pattern.lower()
34
+ filtered = [
35
+ r for r in filtered
36
+ if any(p.name.lower() == pat_lower for p in r.project.patterns)
37
+ ]
38
+
39
+ if tag:
40
+ tag_lower = tag.lower()
41
+ filtered = [
42
+ r for r in filtered
43
+ if any(t.value.lower() == tag_lower for t in r.project.ai_tags)
44
+ ]
45
+
46
+ return filtered
47
+
48
+
49
+ def filter_projects(
50
+ projects: list[Project],
51
+ language: str | None = None,
52
+ framework: str | None = None,
53
+ pattern: str | None = None,
54
+ tag: str | None = None,
55
+ ) -> list[Project]:
56
+ """Apply filters directly to a list of projects."""
57
+ filtered = projects
58
+
59
+ if language:
60
+ lang_lower = language.lower()
61
+ filtered = [
62
+ p for p in filtered
63
+ if p.primary_language and p.primary_language.lower() == lang_lower
64
+ ]
65
+
66
+ if framework:
67
+ fw_lower = framework.lower()
68
+ filtered = [
69
+ p for p in filtered
70
+ if any(f.lower() == fw_lower for f in p.frameworks)
71
+ ]
72
+
73
+ if pattern:
74
+ pat_lower = pattern.lower()
75
+ filtered = [
76
+ p for p in filtered
77
+ if any(pt.name.lower() == pat_lower for pt in p.patterns)
78
+ ]
79
+
80
+ if tag:
81
+ tag_lower = tag.lower()
82
+ filtered = [
83
+ p for p in filtered
84
+ if any(t.value.lower() == tag_lower for t in p.ai_tags)
85
+ ]
86
+
87
+ return filtered
88
+
89
+
90
+ def compute_facets(projects: list[Project]) -> SearchFacets:
91
+ """Compute facet counts from a list of projects."""
92
+ languages: Counter[str] = Counter()
93
+ frameworks: Counter[str] = Counter()
94
+ patterns: Counter[str] = Counter()
95
+ tags: Counter[str] = Counter()
96
+
97
+ for p in projects:
98
+ if p.primary_language:
99
+ languages[p.primary_language] += 1
100
+ for fw in p.frameworks:
101
+ frameworks[fw] += 1
102
+ for pat in p.patterns:
103
+ patterns[pat.name] += 1
104
+ for t in p.ai_tags:
105
+ tags[t.value] += 1
106
+
107
+ return SearchFacets(
108
+ languages=dict(languages.most_common()),
109
+ frameworks=dict(frameworks.most_common()),
110
+ patterns=dict(patterns.most_common()),
111
+ tags=dict(tags.most_common()),
112
+ total=len(projects),
113
+ )