Platform upgrade: semantic search, citations, readiness, tests, Docker
Major features added by 5 parallel agent teams: - Semantic "Ask" (NL queries via FTS5 + embeddings + Claude synthesis) - Global search across drafts, ideas, authors, gaps - REST API expansion (14 endpoints, up from 3) with CSV/JSON export - Citation graph visualization (D3.js, 440 nodes, 2422 edges) - Standards readiness scoring (0-100 composite from 6 factors) - Side-by-side draft comparison view with shared/unique analysis - Annotation system (notes + tags per draft, DB-persisted) - Docker deployment (Dockerfile + docker-compose with Ollama) - Scheduled updates (cron script with log rotation) - Pipeline health dashboard (stage progress bars, cost tracking) - Test suite foundation (54 pytest tests covering DB, models, web data) Fixes: compare_drafts() stubbed→working, get_authors_for_draft() bug, source-aware analysis prompts, config env var overrides + validation, resilient batch error handling with --retry-failed, observatory --dry-run Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
.git
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
paper/
|
||||||
|
.claude/
|
||||||
|
*.egg-info
|
||||||
|
.mypy_cache
|
||||||
|
.pytest_cache
|
||||||
|
data/ietf_drafts.db
|
||||||
|
docs/
|
||||||
24
Dockerfile
Normal file
24
Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install build dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
gcc \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy project metadata and source first for layer caching
|
||||||
|
COPY pyproject.toml .
|
||||||
|
COPY src/ src/
|
||||||
|
|
||||||
|
# Install the package and all dependencies (including flask)
|
||||||
|
RUN pip install --no-cache-dir .
|
||||||
|
|
||||||
|
# Copy data directory (DB, config, reports)
|
||||||
|
COPY data/ data/
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
CMD ["python", "src/webui/app.py"]
|
||||||
32
README.md
32
README.md
@@ -45,8 +45,34 @@ ietf viz all
|
|||||||
|
|
||||||
# Open the interactive browser
|
# Open the interactive browser
|
||||||
xdg-open data/figures/browser.html
|
xdg-open data/figures/browser.html
|
||||||
|
|
||||||
|
# Launch the web dashboard
|
||||||
|
./scripts/run-webui.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Web Dashboard
|
||||||
|
|
||||||
|
A full interactive dashboard at `http://127.0.0.1:5000` with 8 pages:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start the dashboard
|
||||||
|
./scripts/run-webui.sh
|
||||||
|
# or: python src/webui/app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
| Page | What it shows |
|
||||||
|
|------|---------------|
|
||||||
|
| **Overview** | Stat cards, score histogram, category donut, submission timeline, category radar |
|
||||||
|
| **Draft Explorer** | Searchable/filterable/sortable table of all drafts with category pills and score badges |
|
||||||
|
| **Draft Detail** | Individual draft view with score ring, dimension bars, ideas, references, and linked authors |
|
||||||
|
| **Ratings** | Score distributions, dimension box plots, category radar, novelty vs maturity scatter, top-20 leaderboard |
|
||||||
|
| **Landscape** | t-SNE embedding map, quality quadrants, violin plots by category |
|
||||||
|
| **Authors** | Co-authorship force-directed graph, organization charts, cross-org collaboration |
|
||||||
|
| **Ideas** | Extracted ideas grouped by type with search |
|
||||||
|
| **Gaps** | Gap cards sorted by severity with links to related drafts |
|
||||||
|
|
||||||
|
Charts are interactive (Plotly.js) — click data points to navigate to draft details, click categories to filter.
|
||||||
|
|
||||||
## CLI Commands
|
## CLI Commands
|
||||||
|
|
||||||
### Core Pipeline
|
### Core Pipeline
|
||||||
@@ -182,6 +208,7 @@ The safety deficit is the most striking finding — only **12.3%** of categorize
|
|||||||
- **SQLite** with FTS5 full-text search and WAL mode
|
- **SQLite** with FTS5 full-text search and WAL mode
|
||||||
- **Anthropic Claude** (Sonnet 4) for analysis, rating, idea extraction, gap analysis
|
- **Anthropic Claude** (Sonnet 4) for analysis, rating, idea extraction, gap analysis
|
||||||
- **Ollama** (nomic-embed-text) for local embeddings and similarity
|
- **Ollama** (nomic-embed-text) for local embeddings and similarity
|
||||||
|
- **Flask** with Jinja2 for the interactive web dashboard
|
||||||
- **Plotly** for interactive HTML visualizations
|
- **Plotly** for interactive HTML visualizations
|
||||||
- **Matplotlib/Seaborn** for publication-ready static figures
|
- **Matplotlib/Seaborn** for publication-ready static figures
|
||||||
- **NetworkX** for author collaboration graph analysis
|
- **NetworkX** for author collaboration graph analysis
|
||||||
@@ -203,6 +230,11 @@ src/ietf_analyzer/
|
|||||||
draftgen.py # Internet-Draft generation from gap analysis
|
draftgen.py # Internet-Draft generation from gap analysis
|
||||||
config.py # Configuration with defaults
|
config.py # Configuration with defaults
|
||||||
|
|
||||||
|
src/webui/
|
||||||
|
app.py # Flask application with all routes
|
||||||
|
data.py # Data access layer (stats, filtering, t-SNE, network graphs)
|
||||||
|
templates/ # Jinja2 templates (base + 8 page templates)
|
||||||
|
|
||||||
data/
|
data/
|
||||||
drafts.db # SQLite database (all analysis data)
|
drafts.db # SQLite database (all analysis data)
|
||||||
reports/ # Generated markdown reports
|
reports/ # Generated markdown reports
|
||||||
|
|||||||
@@ -4,6 +4,19 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 2026-03-07 CODER C — Citation Graph, Readiness Scoring, Annotations, Data Surfacing
|
||||||
|
|
||||||
|
**What**: Implemented four features in a single session:
|
||||||
|
1. **Citation Graph Visualization** (`/citations`): D3.js force-directed graph showing cross-references between drafts and RFCs. Nodes colored by type (blue=draft, orange=RFC), sized by influence (in-degree). Includes category filter, min-refs slider, hover tooltips, click-to-navigate, and a top-referenced RFCs table. New `get_citation_graph()` in data.py, route + API endpoint in app.py.
|
||||||
|
2. **Standards Readiness Scoring**: New `readiness.py` module computing a 0-100 composite score from 6 weighted factors (WG adoption 25%, revision maturity 15%, reference density 15%, cited-by count 15%, author experience 15%, momentum rating 15%). Displayed as a progress gauge on draft detail pages, added as sortable column on drafts listing, and shown in `ietf show` CLI output.
|
||||||
|
3. **Annotation System**: New `annotations` table in DB schema with `upsert_annotation`, `get_annotation`, `get_all_annotations`, `search_by_tag` methods. New `ietf annotate` CLI command with `--note`, `--tag`, `--remove-tag` options. Web UI: inline note editor + tag chips with add/remove on draft detail page, backed by POST `/api/drafts/<name>/annotate` endpoint.
|
||||||
|
4. **Surface Underutilized Data**: Exposed `novelty_score` (from pipeline/quality.py) in ideas.html and draft_detail.html as color-coded N:X badges. Gap severity now sorts critical-first (was alphabetical). `all_ideas()` and `get_ideas_for_draft()` now return `novelty_score` field.
|
||||||
|
|
||||||
|
**Why**: These features leverage existing data (4231 refs, novelty scores, severity) that was computed but never surfaced to users. Readiness scoring gives a quick at-a-glance RFC proximity signal. Annotations enable user workflow.
|
||||||
|
**Result**: 8 files modified (db.py, data.py, app.py, cli.py, base.html, draft_detail.html, ideas.html, drafts.html, gaps.html), 2 files created (readiness.py, citations.html). Citations link added to sidebar nav.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### 2026-03-06 CODER — Interactive D3.js Author Network Visualization
|
### 2026-03-06 CODER — Interactive D3.js Author Network Visualization
|
||||||
|
|
||||||
**What**: Replaced the Plotly spring-layout co-authorship graph on `/authors` with a full D3.js v7 force-directed network. Added enriched data layer (`get_author_network_full`) with avg draft scores per author, connected-component cluster detection (68 clusters found), and a new `/api/authors/network` JSON endpoint. Template now includes: interactive D3 force graph with zoom/pan/drag, org filter dropdown, cluster highlighting with zoom-to-fit, hover tooltips showing author details + draft list, click-to-navigate, plus the existing Plotly org bar chart, cross-org collaboration chart, sortable authors table (now top 50), and org stats sidebar.
|
**What**: Replaced the Plotly spring-layout co-authorship graph on `/authors` with a full D3.js v7 force-directed network. Added enriched data layer (`get_author_network_full`) with avg draft scores per author, connected-component cluster detection (68 clusters found), and a new `/api/authors/network` JSON endpoint. Template now includes: interactive D3 force graph with zoom/pan/drag, org filter dropdown, cluster highlighting with zoom-to-fit, hover tooltips showing author details + draft list, click-to-navigate, plus the existing Plotly org bar chart, cross-org collaboration chart, sortable authors table (now top 50), and org stats sidebar.
|
||||||
@@ -411,3 +424,23 @@
|
|||||||
- Decisions made: **GitHub Pages** for publication, **staggered 1/day** cadence, **MIT license**
|
- Decisions made: **GitHub Pages** for publication, **staggered 1/day** cadence, **MIT license**
|
||||||
- Agent utilization: Architect (2 tasks, shut down), Writer (2 tasks, shut down), Planner (1 task, shut down)
|
- Agent utilization: Architect (2 tasks, shut down), Writer (2 tasks, shut down), Planner (1 task, shut down)
|
||||||
**Surprise**: The crash recovery was seamless — the dev journal served exactly its intended purpose. Every agent could read the journal and understand the full state without any human explanation. The journal-as-coordination-mechanism is the strongest vindication of the CLAUDE.md journaling requirement. This should feature prominently in Post 8.
|
**Surprise**: The crash recovery was seamless — the dev journal served exactly its intended purpose. Every agent could read the journal and understand the full state without any human explanation. The journal-as-coordination-mechanism is the strongest vindication of the CLAUDE.md journaling requirement. This should feature prominently in Post 8.
|
||||||
|
|
||||||
|
### 2026-03-07 CODER E — W3C Integration, Docker, Scheduling, Pipeline Health
|
||||||
|
|
||||||
|
**What**: Four-part infrastructure sprint to make the platform multi-source, self-running, and deployable:
|
||||||
|
|
||||||
|
1. **W3C Integration** — Wired the existing W3C fetcher (`sources/w3c.py`) into the full pipeline. Made analysis prompts source-aware (`_doc_type_label()` returns "IETF draft" or "W3C specification" based on `source` field). Added source filter dropdown (IETF / W3C / All) to the Draft Explorer web UI with colored source badges (blue for IETF, green for W3C). Updated `get_drafts_page()` to accept `source` parameter. All pagination and sort links preserve source filter state. Config documents how to enable W3C: `ietf observatory update --source w3c` or add `"w3c"` to `observatory_sources` in config.json.
|
||||||
|
|
||||||
|
2. **Scheduled Updates** — Created `scripts/scheduled-update.sh` for cron-based automation. Handles .env loading, log rotation (30 days), and proper error exit codes. Usage: `crontab -e -> 0 6 * * * /path/to/scheduled-update.sh`
|
||||||
|
|
||||||
|
3. **Docker Deployment** — Created `Dockerfile` (python:3.11-slim), `docker-compose.yml` (web + ollama services with volume mounts for data persistence), and `.dockerignore`. One-command deployment: `docker compose up`.
|
||||||
|
|
||||||
|
4. **Pipeline Health** — Enhanced `ietf pipeline status` to show comprehensive health: processing stage breakdown (rated/embedded/ideas with ASCII progress bars), total ideas, gaps, API token usage, estimated cost. Enhanced monitor web page with visual pipeline progress bars, cost tracking panel, and document/idea/gap counts. Added `--dry-run` flag to `ietf observatory update` that previews what would happen. Wrapped all observatory update steps in try/except for graceful error recovery — failures in one stage no longer block subsequent stages.
|
||||||
|
|
||||||
|
**Why**: The platform was IETF-only despite having a complete W3C fetcher. Docker makes deployment reproducible. Scheduled updates make it self-running. Error recovery prevents partial failures from wasting an entire update cycle.
|
||||||
|
|
||||||
|
**Result**:
|
||||||
|
- Files modified: `analyzer.py`, `observatory.py`, `cli.py`, `config.py`, `data.py`, `app.py`, `drafts.html`, `monitor.html`
|
||||||
|
- Files created: `Dockerfile`, `docker-compose.yml`, `.dockerignore`, `scripts/scheduled-update.sh`
|
||||||
|
- All Python files compile cleanly
|
||||||
|
- No breaking changes to existing IETF-only workflows
|
||||||
|
|||||||
182
data/reports/platform-improvement-plan.md
Normal file
182
data/reports/platform-improvement-plan.md
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
# Platform Improvement Plan: IETF Draft Analyzer → Standards Intelligence Platform
|
||||||
|
|
||||||
|
*Generated 2026-03-07 — Based on full codebase audit and architectural analysis*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State Summary
|
||||||
|
|
||||||
|
| Dimension | What Exists | Assessment |
|
||||||
|
|-----------|-------------|------------|
|
||||||
|
| **Data** | 434 drafts, 557 authors, 419 ideas, 11 gaps, 4231 refs to 694 RFCs | Strong foundation |
|
||||||
|
| **CLI** | 20+ commands (fetch, analyze, embed, ideas, gaps, report, viz, wg, etc.) | Feature-rich |
|
||||||
|
| **Web UI** | 16 Flask pages with D3.js/Plotly visualizations | Good but disconnected |
|
||||||
|
| **Pipeline** | Observatory class with multi-source support | Built but manual |
|
||||||
|
| **Multi-SDO** | IETF complete, W3C fetcher written but unused | Partially built |
|
||||||
|
| **Tests** | Zero tests | Critical gap |
|
||||||
|
| **Deployment** | Manual Python setup | No containerization |
|
||||||
|
|
||||||
|
## The Transformation
|
||||||
|
|
||||||
|
**From**: A powerful CLI analysis tool that an expert runs manually
|
||||||
|
**To**: A living intelligence platform that monitors, alerts, and answers questions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Quick Wins (All Parallelizable)
|
||||||
|
|
||||||
|
### 1.1 Global Search [S: 3-5h]
|
||||||
|
Add a unified search bar to the web UI that queries across drafts (FTS5), ideas, authors, and gaps simultaneously. Currently search is isolated to the drafts page.
|
||||||
|
|
||||||
|
- **Files**: `src/webui/app.py` (new `/search` route), `src/webui/data.py` (new `global_search()`), `src/webui/templates/base.html` (search in sidebar), new `search_results.html`
|
||||||
|
- **Why first**: Most common user action on any data platform
|
||||||
|
|
||||||
|
### 1.2 REST API [S: 2-3h]
|
||||||
|
Expose all existing `data.py` functions as JSON endpoints. Only 3 API endpoints exist today — extend to all 15+ data views.
|
||||||
|
|
||||||
|
- **Files**: `src/webui/app.py` (add `/api/ideas`, `/api/gaps`, `/api/ratings`, `/api/timeline`, `/api/landscape`, `/api/similarity`, `/api/drafts/<name>`, etc.)
|
||||||
|
- **Why**: Enables programmatic access, third-party tools, decouples data from presentation
|
||||||
|
|
||||||
|
### 1.3 Export (CSV/JSON) [S: 3-4h]
|
||||||
|
Add export buttons to web UI pages + `ietf export` CLI command.
|
||||||
|
|
||||||
|
- **Files**: `src/ietf_analyzer/cli.py` (new `export` command), API endpoints get `?format=csv` support
|
||||||
|
- **Depends on**: 1.2
|
||||||
|
|
||||||
|
### 1.4 Annotation System [M: 4-6h]
|
||||||
|
Add private notes and custom tags per draft, persisted in DB.
|
||||||
|
|
||||||
|
- **Files**: `src/ietf_analyzer/db.py` (new `annotations` table), `src/webui/app.py` (POST endpoint), `src/webui/templates/draft_detail.html` (inline edit), `src/ietf_analyzer/cli.py` (new `annotate` command)
|
||||||
|
- **Why**: Analysts need to layer their own context onto the data
|
||||||
|
|
||||||
|
### 1.5 Test Suite Foundation [M: 6-8h]
|
||||||
|
Create pytest infrastructure with ~30 tests covering DB layer, models, and web data functions.
|
||||||
|
|
||||||
|
- **Files**: new `tests/conftest.py`, `tests/test_db.py`, `tests/test_models.py`, `tests/test_data.py`, update `pyproject.toml`
|
||||||
|
- **Why**: Zero tests today. Every future change benefits from a safety net
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Core Platform
|
||||||
|
|
||||||
|
### 2.1 Semantic Search / "Ask" [M: 1-2 days] — THE SIGNATURE FEATURE
|
||||||
|
Natural language queries: "Which drafts address agent authentication?" → synthesized answer with citations.
|
||||||
|
|
||||||
|
- Embed the query via Ollama, compute cosine similarity against all 434 draft embeddings
|
||||||
|
- Merge with FTS5 keyword results using reciprocal rank fusion
|
||||||
|
- Optionally synthesize answer via Claude with top-K context
|
||||||
|
- **Files**: new `src/ietf_analyzer/search.py`, `src/ietf_analyzer/cli.py` (`ietf ask`), `src/webui/app.py` (`/ask` route), new template
|
||||||
|
|
||||||
|
### 2.2 Competitive Landscape Mapping [M: 1-2 days]
|
||||||
|
Auto-detect and compare competing proposals in the same problem space.
|
||||||
|
|
||||||
|
- Group drafts by similarity clusters, enrich with rating comparisons and WG adoption status
|
||||||
|
- Show head-to-head comparisons: where they agree, where they diverge
|
||||||
|
- **Files**: new `src/ietf_analyzer/competition.py`, `src/webui/app.py` (`/competition` route), new template
|
||||||
|
|
||||||
|
### 2.3 Standards Readiness Scoring [M: 1 day]
|
||||||
|
Composite 0-100 "readiness" score: WG adoption, revision count, reference density, cited-by count, author track record.
|
||||||
|
|
||||||
|
- **Files**: new `src/ietf_analyzer/readiness.py`, update `src/webui/templates/draft_detail.html` (gauge chart), update drafts listing
|
||||||
|
|
||||||
|
### 2.4 Scheduled Updates + Pipeline Health [M: 1 day]
|
||||||
|
Cron-based auto-fetch using existing Observatory, plus monitoring dashboard.
|
||||||
|
|
||||||
|
- **Files**: new `scripts/scheduled-update.sh`, enhance `src/webui/templates/monitor.html` (stage breakdown, cost tracking, failure log)
|
||||||
|
|
||||||
|
### 2.5 Comparison View [M: 1 day]
|
||||||
|
Side-by-side comparison of 2+ drafts with rating radar overlay, shared/unique ideas, shared/unique references.
|
||||||
|
|
||||||
|
- **Files**: `src/webui/app.py` (`/compare?drafts=...`), `src/webui/data.py` (`get_comparison_data()`), new template, add checkboxes to drafts listing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Intelligence
|
||||||
|
|
||||||
|
### 3.1 Trend Forecasting [L: 2-3 days]
|
||||||
|
Predict which areas will grow based on submission velocity, revision activity, WG adoption signals.
|
||||||
|
|
||||||
|
- Per-category momentum signals, linear/exponential extrapolation, "Hot/Cooling/Emerging" tags
|
||||||
|
- **Files**: new `src/ietf_analyzer/forecasting.py`, new web page `/trends`
|
||||||
|
|
||||||
|
### 3.2 Change Detection & Diffing [L: 2-3 days]
|
||||||
|
Track what changed between draft revisions, summarize changes via Claude.
|
||||||
|
|
||||||
|
- New `draft_revisions` table to archive old versions
|
||||||
|
- Section-level diff with Claude-generated change summaries
|
||||||
|
- **Files**: new `src/ietf_analyzer/diff.py`, update `src/ietf_analyzer/db.py`, `src/ietf_analyzer/fetcher.py`
|
||||||
|
- **Depends on**: 2.4 (scheduled updates to catch new revisions)
|
||||||
|
|
||||||
|
### 3.3 Citation Graph [M: 1-2 days]
|
||||||
|
Visual dependency tree from the 4231 existing cross-references to 694 RFCs.
|
||||||
|
|
||||||
|
- D3.js force-directed graph (pattern from authors.html), PageRank-style influence scores
|
||||||
|
- **Files**: `src/webui/data.py` (`get_citation_graph()`), new template `citations.html`
|
||||||
|
|
||||||
|
### 3.4 Newsletter Generation [M: 1-2 days]
|
||||||
|
Automated weekly/monthly digest with new drafts, significant changes, trend shifts.
|
||||||
|
|
||||||
|
- **Files**: new `src/ietf_analyzer/newsletter.py`, `src/ietf_analyzer/cli.py` (`ietf newsletter`)
|
||||||
|
- **Depends on**: 3.2 (for change content)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Scale
|
||||||
|
|
||||||
|
### 4.1 Docker Deployment [S: 3-4h]
|
||||||
|
Dockerfile + docker-compose.yml with Flask app + Ollama.
|
||||||
|
|
||||||
|
### 4.2 Complete W3C Integration [M: 1 day]
|
||||||
|
Wire existing W3C fetcher into Observatory pipeline, make prompts source-aware, add source filter to web UI.
|
||||||
|
|
||||||
|
### 4.3 IEEE + 3GPP Sources [L: 3-5 days]
|
||||||
|
New source fetchers following `SourceFetcher` protocol. Depends on 4.2 validating the pipeline.
|
||||||
|
|
||||||
|
### 4.4 Cross-SDO Analysis [L: 2-3 days]
|
||||||
|
Compare work across standards bodies. Embedding similarity between IETF/W3C/IEEE specs, gap analysis for topics only one body covers.
|
||||||
|
|
||||||
|
### 4.5 Plugin Architecture [L: 2-3 days]
|
||||||
|
Formalize extension points for sources, analyzers, report types via Python entry points.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Agent Team Assignments
|
||||||
|
|
||||||
|
### Immediate Sprint (Phase 1 — all in parallel)
|
||||||
|
|
||||||
|
| Agent | Items | Focus |
|
||||||
|
|-------|-------|-------|
|
||||||
|
| **Coder A** | 1.1 + 1.2 | Global search + REST API |
|
||||||
|
| **Coder B** | 1.4 + 1.3 | Annotations + Export |
|
||||||
|
| **Coder C** | 1.5 | Test suite foundation |
|
||||||
|
|
||||||
|
### Next Sprint (Phase 2 — mostly parallel)
|
||||||
|
|
||||||
|
| Agent | Items | Focus |
|
||||||
|
|-------|-------|-------|
|
||||||
|
| **Coder A** | 2.1 | Semantic search / Ask (signature feature) |
|
||||||
|
| **Coder B** | 2.2 + 2.5 | Competition mapping + Comparison view |
|
||||||
|
| **Coder C** | 2.3 + 2.4 | Readiness scoring + Pipeline health |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Impact vs Effort Matrix
|
||||||
|
|
||||||
|
```
|
||||||
|
HIGH IMPACT
|
||||||
|
|
|
||||||
|
| 2.1 Ask 3.2 Diffing
|
||||||
|
| 1.1 Search 3.1 Forecasting
|
||||||
|
| 2.2 Competition 4.4 Cross-SDO
|
||||||
|
| 2.5 Compare
|
||||||
|
| 1.2 API
|
||||||
|
| 2.3 Readiness 3.3 Citations
|
||||||
|
| 1.4 Annotations 3.4 Newsletter
|
||||||
|
| 1.3 Export 4.2 W3C
|
||||||
|
| 1.5 Tests 4.1 Docker
|
||||||
|
| 4.5 Plugins
|
||||||
|
| 4.3 IEEE/3GPP
|
||||||
|
LOW IMPACT
|
||||||
|
+------------------------------------------
|
||||||
|
LOW EFFORT HIGH EFFORT
|
||||||
|
```
|
||||||
26
docker-compose.yml
Normal file
26
docker-compose.yml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
environment:
|
||||||
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||||
|
- IETF_ANALYZER_OLLAMA_URL=http://ollama:11434
|
||||||
|
depends_on:
|
||||||
|
- ollama
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
ollama:
|
||||||
|
image: ollama/ollama
|
||||||
|
volumes:
|
||||||
|
- ollama_data:/root/.ollama
|
||||||
|
ports:
|
||||||
|
- "11434:11434"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
ollama_data:
|
||||||
@@ -22,10 +22,17 @@ dependencies = [
|
|||||||
"scikit-learn>=1.3",
|
"scikit-learn>=1.3",
|
||||||
"networkx>=3.2",
|
"networkx>=3.2",
|
||||||
"markdown>=3.5",
|
"markdown>=3.5",
|
||||||
|
"flask>=3.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
test = ["pytest", "pytest-cov"]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
ietf = "ietf_analyzer.cli:main"
|
ietf = "ietf_analyzer.cli:main"
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
pythonpath = ["src"]
|
||||||
|
|||||||
61
scripts/scheduled-update.sh
Executable file
61
scripts/scheduled-update.sh
Executable file
@@ -0,0 +1,61 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Scheduled observatory update -- run via cron
|
||||||
|
# Usage: crontab -e -> 0 6 * * * /path/to/scheduled-update.sh
|
||||||
|
#
|
||||||
|
# This script runs the full observatory update cycle:
|
||||||
|
# 1. Fetch new docs from all enabled sources (IETF, W3C)
|
||||||
|
# 2. Analyze unrated documents with Claude
|
||||||
|
# 3. Generate embeddings with Ollama
|
||||||
|
# 4. Extract ideas from new documents
|
||||||
|
# 5. Re-run gap analysis if enough new docs
|
||||||
|
#
|
||||||
|
# Logs are saved to data/logs/update-YYYYMMDD-HHMMSS.log
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
LOG_DIR="data/logs"
|
||||||
|
mkdir -p "$LOG_DIR"
|
||||||
|
LOG_FILE="$LOG_DIR/update-$(date +%Y%m%d-%H%M%S).log"
|
||||||
|
|
||||||
|
echo "Starting scheduled update at $(date)" | tee "$LOG_FILE"
|
||||||
|
|
||||||
|
# Load environment (API keys, etc.)
|
||||||
|
if [ -f .env ]; then
|
||||||
|
set -a
|
||||||
|
source .env
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run the observatory update (delta mode -- only fetch new docs)
|
||||||
|
python -c "
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, 'src')
|
||||||
|
from ietf_analyzer.observatory import Observatory
|
||||||
|
from ietf_analyzer.analyzer import Analyzer
|
||||||
|
from ietf_analyzer.config import Config
|
||||||
|
from ietf_analyzer.db import Database
|
||||||
|
|
||||||
|
config = Config.load()
|
||||||
|
db = Database(config)
|
||||||
|
analyzer = Analyzer(config, db)
|
||||||
|
obs = Observatory(config, db, analyzer)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = obs.update()
|
||||||
|
print(f'Results: {result}')
|
||||||
|
except Exception as e:
|
||||||
|
print(f'ERROR: {e}', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
" >> "$LOG_FILE" 2>&1
|
||||||
|
|
||||||
|
EXIT_CODE=$?
|
||||||
|
|
||||||
|
echo "Completed at $(date) (exit code: $EXIT_CODE)" | tee -a "$LOG_FILE"
|
||||||
|
|
||||||
|
# Clean up old logs (keep last 30 days)
|
||||||
|
find "$LOG_DIR" -name "update-*.log" -mtime +30 -delete 2>/dev/null || true
|
||||||
|
|
||||||
|
exit $EXIT_CODE
|
||||||
@@ -38,7 +38,7 @@ CATEGORIES_SHORT = [
|
|||||||
|
|
||||||
# Compact prompt — abstract only, saves ~10x tokens vs full-text
|
# Compact prompt — abstract only, saves ~10x tokens vs full-text
|
||||||
RATE_PROMPT_COMPACT = """\
|
RATE_PROMPT_COMPACT = """\
|
||||||
Rate this IETF draft. JSON only.
|
Rate this {doc_type}. JSON only.
|
||||||
|
|
||||||
{name} | {title} | {time} | {pages}pg
|
{name} | {title} | {time} | {pages}pg
|
||||||
Abstract: {abstract}
|
Abstract: {abstract}
|
||||||
@@ -51,7 +51,7 @@ JSON only, no fences."""
|
|||||||
|
|
||||||
# Batch prompt — rate multiple drafts in one call
|
# Batch prompt — rate multiple drafts in one call
|
||||||
BATCH_PROMPT = """\
|
BATCH_PROMPT = """\
|
||||||
Rate each IETF draft below. Return a JSON array with one object per draft, in order.
|
Rate each document below. Return a JSON array with one object per draft, in order.
|
||||||
|
|
||||||
{drafts_block}
|
{drafts_block}
|
||||||
|
|
||||||
@@ -62,14 +62,14 @@ Categories: {categories}
|
|||||||
Return ONLY a JSON array, no fences."""
|
Return ONLY a JSON array, no fences."""
|
||||||
|
|
||||||
COMPARE_PROMPT = """\
|
COMPARE_PROMPT = """\
|
||||||
Compare these IETF drafts — overlaps, unique ideas, complementary vs competing vs redundant.
|
Compare these documents — overlaps, unique ideas, complementary vs competing vs redundant.
|
||||||
|
|
||||||
{drafts_section}
|
{drafts_section}
|
||||||
|
|
||||||
Be specific about concrete mechanisms and design choices."""
|
Be specific about concrete mechanisms and design choices."""
|
||||||
|
|
||||||
EXTRACT_IDEAS_PROMPT = """\
|
EXTRACT_IDEAS_PROMPT = """\
|
||||||
Extract discrete technical ideas and mechanisms from this IETF draft.
|
Extract discrete technical ideas and mechanisms from this {doc_type}.
|
||||||
Return a JSON array. Each element: {{"title":"short name","description":"1-2 sentences","type":"mechanism|protocol|pattern|requirement|architecture|extension"}}
|
Return a JSON array. Each element: {{"title":"short name","description":"1-2 sentences","type":"mechanism|protocol|pattern|requirement|architecture|extension"}}
|
||||||
|
|
||||||
{name} | {title} | {pages}pg
|
{name} | {title} | {pages}pg
|
||||||
@@ -81,7 +81,7 @@ Return 1-4 ideas. Extract only TOP-LEVEL novel contributions. Do NOT list sub-fe
|
|||||||
JSON array only, no fences."""
|
JSON array only, no fences."""
|
||||||
|
|
||||||
BATCH_IDEAS_PROMPT = """\
|
BATCH_IDEAS_PROMPT = """\
|
||||||
Extract ideas from each IETF draft below. Return a JSON object mapping draft name -> array of ideas.
|
Extract ideas from each document below. Return a JSON object mapping document name -> array of ideas.
|
||||||
Per idea: {{"title":"short name","description":"1 sentence","type":"mechanism|protocol|pattern|requirement|architecture|extension"}}
|
Per idea: {{"title":"short name","description":"1 sentence","type":"mechanism|protocol|pattern|requirement|architecture|extension"}}
|
||||||
|
|
||||||
{drafts_block}
|
{drafts_block}
|
||||||
@@ -135,6 +135,15 @@ def _prompt_hash(text: str) -> str:
|
|||||||
return hashlib.sha256(text.encode()).hexdigest()[:16]
|
return hashlib.sha256(text.encode()).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def _doc_type_label(source: str) -> str:
|
||||||
|
"""Return a human-readable document type based on source."""
|
||||||
|
labels = {
|
||||||
|
"ietf": "IETF draft",
|
||||||
|
"w3c": "W3C specification",
|
||||||
|
}
|
||||||
|
return labels.get(source, f"{source} document")
|
||||||
|
|
||||||
|
|
||||||
class Analyzer:
|
class Analyzer:
|
||||||
def __init__(self, config: Config | None = None, db: Database | None = None):
|
def __init__(self, config: Config | None = None, db: Database | None = None):
|
||||||
self.config = config or Config.load()
|
self.config = config or Config.load()
|
||||||
@@ -199,6 +208,7 @@ class Analyzer:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
prompt = RATE_PROMPT_COMPACT.format(
|
prompt = RATE_PROMPT_COMPACT.format(
|
||||||
|
doc_type=_doc_type_label(draft.source),
|
||||||
name=draft.name, title=draft.title, time=draft.date,
|
name=draft.name, title=draft.title, time=draft.date,
|
||||||
pages=draft.pages or "?",
|
pages=draft.pages or "?",
|
||||||
abstract=draft.abstract[:2000],
|
abstract=draft.abstract[:2000],
|
||||||
@@ -302,6 +312,7 @@ class Analyzer:
|
|||||||
|
|
||||||
console.print(f"Rating [bold]{len(unrated)}[/] drafts in batches of {batch_size}...")
|
console.print(f"Rating [bold]{len(unrated)}[/] drafts in batches of {batch_size}...")
|
||||||
count = 0
|
count = 0
|
||||||
|
failures: list[tuple[str, str]] = []
|
||||||
with Progress(
|
with Progress(
|
||||||
SpinnerColumn(),
|
SpinnerColumn(),
|
||||||
TextColumn("[progress.description]{task.description}"),
|
TextColumn("[progress.description]{task.description}"),
|
||||||
@@ -314,15 +325,29 @@ class Analyzer:
|
|||||||
batch = unrated[i:i + batch_size]
|
batch = unrated[i:i + batch_size]
|
||||||
names = ", ".join(d.name.split("-")[-1][:12] for d in batch)
|
names = ", ".join(d.name.split("-")[-1][:12] for d in batch)
|
||||||
progress.update(task, description=f"Batch: {names}")
|
progress.update(task, description=f"Batch: {names}")
|
||||||
|
try:
|
||||||
n = self.rate_batch(batch, batch_size=batch_size)
|
n = self.rate_batch(batch, batch_size=batch_size)
|
||||||
count += n
|
count += n
|
||||||
|
except Exception as e:
|
||||||
|
batch_names = [d.name for d in batch]
|
||||||
|
for bn in batch_names:
|
||||||
|
failures.append((bn, str(e)))
|
||||||
|
console.print(f"[red]Batch failed: {e}[/]")
|
||||||
progress.advance(task, advance=len(batch))
|
progress.advance(task, advance=len(batch))
|
||||||
|
|
||||||
in_tok, out_tok = self.db.total_tokens_used()
|
in_tok, out_tok = self.db.total_tokens_used()
|
||||||
|
total_attempted = len(unrated)
|
||||||
console.print(
|
console.print(
|
||||||
f"Rated [bold green]{count}[/] drafts "
|
f"Rated [bold green]{count}[/] drafts "
|
||||||
f"| Total tokens used: {in_tok:,} in + {out_tok:,} out"
|
f"| Total tokens used: {in_tok:,} in + {out_tok:,} out"
|
||||||
)
|
)
|
||||||
|
if failures:
|
||||||
|
console.print(
|
||||||
|
f"[yellow]Processed {count}/{total_attempted} drafts, "
|
||||||
|
f"{len(failures)} failure(s):[/]"
|
||||||
|
)
|
||||||
|
for name, err in failures[:20]:
|
||||||
|
console.print(f" [red]{name}[/]: {err}")
|
||||||
return count
|
return count
|
||||||
|
|
||||||
def extract_ideas(self, draft_name: str, use_cache: bool = True) -> list[dict] | None:
|
def extract_ideas(self, draft_name: str, use_cache: bool = True) -> list[dict] | None:
|
||||||
@@ -337,6 +362,7 @@ class Analyzer:
|
|||||||
text_excerpt = draft.full_text[:3000]
|
text_excerpt = draft.full_text[:3000]
|
||||||
|
|
||||||
prompt = EXTRACT_IDEAS_PROMPT.format(
|
prompt = EXTRACT_IDEAS_PROMPT.format(
|
||||||
|
doc_type=_doc_type_label(draft.source),
|
||||||
name=draft.name, title=draft.title,
|
name=draft.name, title=draft.title,
|
||||||
pages=draft.pages or "?",
|
pages=draft.pages or "?",
|
||||||
abstract=draft.abstract[:2000],
|
abstract=draft.abstract[:2000],
|
||||||
@@ -451,6 +477,7 @@ class Analyzer:
|
|||||||
console.print(f"Extracting ideas from [bold]{len(missing)}[/] drafts ({model_label})...")
|
console.print(f"Extracting ideas from [bold]{len(missing)}[/] drafts ({model_label})...")
|
||||||
|
|
||||||
count = 0
|
count = 0
|
||||||
|
failures: list[tuple[str, str]] = []
|
||||||
with Progress(
|
with Progress(
|
||||||
SpinnerColumn(),
|
SpinnerColumn(),
|
||||||
TextColumn("[progress.description]{task.description}"),
|
TextColumn("[progress.description]{task.description}"),
|
||||||
@@ -465,23 +492,40 @@ class Analyzer:
|
|||||||
batch = missing[i:i + batch_size]
|
batch = missing[i:i + batch_size]
|
||||||
names = ", ".join(n.split("-")[-1][:10] for n in batch)
|
names = ", ".join(n.split("-")[-1][:10] for n in batch)
|
||||||
progress.update(task, description=f"Batch: {names}")
|
progress.update(task, description=f"Batch: {names}")
|
||||||
|
try:
|
||||||
n = self.extract_ideas_batch(batch, cheap=cheap)
|
n = self.extract_ideas_batch(batch, cheap=cheap)
|
||||||
count += n
|
count += n
|
||||||
|
except Exception as e:
|
||||||
|
for bn in batch:
|
||||||
|
failures.append((bn, str(e)))
|
||||||
|
console.print(f"[red]Batch failed: {e}[/]")
|
||||||
progress.advance(task, advance=len(batch))
|
progress.advance(task, advance=len(batch))
|
||||||
else:
|
else:
|
||||||
for name in missing:
|
for name in missing:
|
||||||
progress.update(task, description=f"Ideas: {name.split('-')[-1][:15]}")
|
progress.update(task, description=f"Ideas: {name.split('-')[-1][:15]}")
|
||||||
|
try:
|
||||||
result = self.extract_ideas(name)
|
result = self.extract_ideas(name)
|
||||||
if result:
|
if result:
|
||||||
count += 1
|
count += 1
|
||||||
|
except Exception as e:
|
||||||
|
failures.append((name, str(e)))
|
||||||
|
console.print(f"[red]Failed {name}: {e}[/]")
|
||||||
progress.advance(task)
|
progress.advance(task)
|
||||||
|
|
||||||
|
total_attempted = len(missing)
|
||||||
in_tok, out_tok = self.db.total_tokens_used()
|
in_tok, out_tok = self.db.total_tokens_used()
|
||||||
console.print(
|
console.print(
|
||||||
f"Extracted ideas from [bold green]{count}[/] drafts "
|
f"Extracted ideas from [bold green]{count}[/] drafts "
|
||||||
f"({self.db.idea_count()} total ideas) "
|
f"({self.db.idea_count()} total ideas) "
|
||||||
f"| Tokens: {in_tok:,} in + {out_tok:,} out"
|
f"| Tokens: {in_tok:,} in + {out_tok:,} out"
|
||||||
)
|
)
|
||||||
|
if failures:
|
||||||
|
console.print(
|
||||||
|
f"[yellow]Processed {count}/{total_attempted} drafts, "
|
||||||
|
f"{len(failures)} failure(s):[/]"
|
||||||
|
)
|
||||||
|
for name, err in failures[:20]:
|
||||||
|
console.print(f" [red]{name}[/]: {err}")
|
||||||
return count
|
return count
|
||||||
|
|
||||||
def gap_analysis(self) -> list[dict]:
|
def gap_analysis(self) -> list[dict]:
|
||||||
@@ -551,28 +595,49 @@ class Analyzer:
|
|||||||
console.print(f"[red]Gap analysis failed: {e}[/]")
|
console.print(f"[red]Gap analysis failed: {e}[/]")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def compare_drafts(self, draft_names: list[str]) -> str:
|
def compare_drafts(self, draft_names: list[str], use_cache: bool = True) -> dict:
|
||||||
"""Compare multiple drafts and return analysis text."""
|
"""Compare multiple drafts and return structured comparison.
|
||||||
|
|
||||||
|
Returns dict with keys: text, drafts (list of names that were compared),
|
||||||
|
or a dict with key 'error' on failure.
|
||||||
|
"""
|
||||||
|
valid_names = []
|
||||||
parts = []
|
parts = []
|
||||||
for name in draft_names:
|
for name in draft_names:
|
||||||
draft = self.db.get_draft(name)
|
draft = self.db.get_draft(name)
|
||||||
if draft is None:
|
if draft is None:
|
||||||
console.print(f"[yellow]Skipping unknown draft: {name}[/]")
|
console.print(f"[yellow]Skipping unknown draft: {name}[/]")
|
||||||
continue
|
continue
|
||||||
|
valid_names.append(name)
|
||||||
parts.append(f"### {draft.title}\n**{name}**\n{draft.abstract}")
|
parts.append(f"### {draft.title}\n**{name}**\n{draft.abstract}")
|
||||||
|
|
||||||
if len(parts) < 2:
|
if len(parts) < 2:
|
||||||
return "Need at least 2 valid drafts to compare."
|
return {"error": "Need at least 2 valid drafts to compare.", "drafts": valid_names}
|
||||||
|
|
||||||
prompt = COMPARE_PROMPT.format(
|
prompt = COMPARE_PROMPT.format(
|
||||||
drafts_section="\n\n---\n\n".join(parts)
|
drafts_section="\n\n---\n\n".join(parts)
|
||||||
)
|
)
|
||||||
|
phash = _prompt_hash(prompt)
|
||||||
|
cache_key = "_compare_" + "_".join(sorted(valid_names))
|
||||||
|
|
||||||
|
# Check cache
|
||||||
|
if use_cache:
|
||||||
|
cached = self.db.get_cached_response(cache_key, phash)
|
||||||
|
if cached:
|
||||||
|
return {"text": cached, "drafts": valid_names}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
text, _, _ = self._call_claude(prompt, max_tokens=2048)
|
text, in_tok, out_tok = self._call_claude(prompt, max_tokens=2048)
|
||||||
return text
|
|
||||||
|
# Cache the result
|
||||||
|
self.db.cache_response(
|
||||||
|
cache_key, phash, self.config.claude_model,
|
||||||
|
prompt, text, in_tok, out_tok,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"text": text, "drafts": valid_names}
|
||||||
except anthropic.APIError as e:
|
except anthropic.APIError as e:
|
||||||
return f"Error: {e}"
|
return {"error": f"API error: {e}", "drafts": valid_names}
|
||||||
|
|
||||||
def dedup_ideas(self, threshold: float = 0.85, dry_run: bool = True,
|
def dedup_ideas(self, threshold: float = 0.85, dry_run: bool = True,
|
||||||
draft_name: str | None = None) -> dict:
|
draft_name: str | None = None) -> dict:
|
||||||
|
|||||||
@@ -173,6 +173,20 @@ def show(name: str):
|
|||||||
else:
|
else:
|
||||||
console.print("[dim]Not yet rated — run: ietf analyze {name}[/]")
|
console.print("[dim]Not yet rated — run: ietf analyze {name}[/]")
|
||||||
|
|
||||||
|
# Readiness score
|
||||||
|
from .readiness import compute_readiness
|
||||||
|
readiness = compute_readiness(db, name)
|
||||||
|
if readiness["score"] > 0:
|
||||||
|
console.print(f"\n[bold]Standards Readiness: [cyan]{readiness['score']}/100[/][/]")
|
||||||
|
rtable = Table(show_header=True)
|
||||||
|
rtable.add_column("Factor", width=20)
|
||||||
|
rtable.add_column("Value", justify="center", width=10)
|
||||||
|
rtable.add_column("Points", justify="right", width=8)
|
||||||
|
rtable.add_column("Detail")
|
||||||
|
for key, f in readiness["factors"].items():
|
||||||
|
rtable.add_row(f["label"], f"{f['value']:.2f}", f"+{f['contribution']}", f["detail"])
|
||||||
|
console.print(rtable)
|
||||||
|
|
||||||
# Save detailed report too
|
# Save detailed report too
|
||||||
path = reporter.draft_detail(name)
|
path = reporter.draft_detail(name)
|
||||||
if path:
|
if path:
|
||||||
@@ -181,6 +195,56 @@ def show(name: str):
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ── annotate ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@main.command()
|
||||||
|
@click.argument("draft_name")
|
||||||
|
@click.option("--note", "-n", default=None, help="Set/update the note text")
|
||||||
|
@click.option("--tag", "-t", multiple=True, help="Add a tag (can be used multiple times)")
|
||||||
|
@click.option("--remove-tag", "-r", multiple=True, help="Remove a tag (can be used multiple times)")
|
||||||
|
def annotate(draft_name: str, note: str | None, tag: tuple[str, ...], remove_tag: tuple[str, ...]):
|
||||||
|
"""Add or view annotations (notes & tags) for a draft."""
|
||||||
|
cfg = _get_config()
|
||||||
|
db = Database(cfg)
|
||||||
|
try:
|
||||||
|
draft = db.get_draft(draft_name)
|
||||||
|
if draft is None:
|
||||||
|
console.print(f"[red]Draft not found: {draft_name}[/]")
|
||||||
|
return
|
||||||
|
|
||||||
|
# If no options, display current annotation
|
||||||
|
if note is None and not tag and not remove_tag:
|
||||||
|
ann = db.get_annotation(draft_name)
|
||||||
|
if ann:
|
||||||
|
console.print(f"\n[bold]Annotation for {draft_name}[/]")
|
||||||
|
console.print(f" Note: {ann['note'] or '(empty)'}")
|
||||||
|
console.print(f" Tags: {', '.join(ann['tags']) if ann['tags'] else '(none)'}")
|
||||||
|
console.print(f" Updated: {ann['updated_at']}")
|
||||||
|
else:
|
||||||
|
console.print(f"[dim]No annotation for {draft_name}. Use --note or --tag to add one.[/]")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Fetch existing tags for add/remove operations
|
||||||
|
existing = db.get_annotation(draft_name)
|
||||||
|
current_tags = existing["tags"] if existing else []
|
||||||
|
|
||||||
|
for t in tag:
|
||||||
|
if t not in current_tags:
|
||||||
|
current_tags.append(t)
|
||||||
|
for t in remove_tag:
|
||||||
|
if t in current_tags:
|
||||||
|
current_tags.remove(t)
|
||||||
|
|
||||||
|
db.upsert_annotation(draft_name, note=note, tags=current_tags)
|
||||||
|
ann = db.get_annotation(draft_name)
|
||||||
|
console.print(f"[green]Annotation updated for {draft_name}[/]")
|
||||||
|
console.print(f" Note: {ann['note'] or '(empty)'}")
|
||||||
|
console.print(f" Tags: {', '.join(ann['tags']) if ann['tags'] else '(none)'}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
# ── analyze ──────────────────────────────────────────────────────────────────
|
# ── analyze ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -188,7 +252,8 @@ def show(name: str):
|
|||||||
@click.argument("name", required=False)
|
@click.argument("name", required=False)
|
||||||
@click.option("--all", "analyze_all", is_flag=True, help="Analyze all unrated drafts")
|
@click.option("--all", "analyze_all", is_flag=True, help="Analyze all unrated drafts")
|
||||||
@click.option("--limit", "-n", default=50, help="Max drafts to analyze (with --all)")
|
@click.option("--limit", "-n", default=50, help="Max drafts to analyze (with --all)")
|
||||||
def analyze(name: str | None, analyze_all: bool, limit: int):
|
@click.option("--retry-failed", is_flag=True, help="Re-analyze drafts that previously failed (clears cache)")
|
||||||
|
def analyze(name: str | None, analyze_all: bool, limit: int, retry_failed: bool):
|
||||||
"""Analyze and rate drafts using Claude."""
|
"""Analyze and rate drafts using Claude."""
|
||||||
from .analyzer import Analyzer
|
from .analyzer import Analyzer
|
||||||
|
|
||||||
@@ -197,7 +262,29 @@ def analyze(name: str | None, analyze_all: bool, limit: int):
|
|||||||
analyzer = Analyzer(cfg, db)
|
analyzer = Analyzer(cfg, db)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if analyze_all:
|
if retry_failed:
|
||||||
|
# Find drafts that have cache entries but no ratings (failed analyses)
|
||||||
|
unrated = db.unrated_drafts(limit=limit)
|
||||||
|
retryable = []
|
||||||
|
for draft in unrated:
|
||||||
|
# Check if there's a cache entry for this draft (it was attempted)
|
||||||
|
row = db.conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM llm_cache WHERE draft_name = ?",
|
||||||
|
(draft.name,),
|
||||||
|
).fetchone()
|
||||||
|
if row[0] > 0:
|
||||||
|
retryable.append(draft)
|
||||||
|
if not retryable:
|
||||||
|
console.print("No previously failed drafts to retry.")
|
||||||
|
else:
|
||||||
|
console.print(f"Retrying [bold]{len(retryable)}[/] previously failed drafts...")
|
||||||
|
count = 0
|
||||||
|
for draft in retryable:
|
||||||
|
rating = analyzer.rate_draft(draft.name, use_cache=False)
|
||||||
|
if rating:
|
||||||
|
count += 1
|
||||||
|
console.print(f"Successfully re-analyzed [bold green]{count}[/] of {len(retryable)} drafts")
|
||||||
|
elif analyze_all:
|
||||||
count = analyzer.rate_all_unrated(limit=limit)
|
count = analyzer.rate_all_unrated(limit=limit)
|
||||||
console.print(f"Analyzed [bold green]{count}[/] drafts")
|
console.print(f"Analyzed [bold green]{count}[/] drafts")
|
||||||
elif name:
|
elif name:
|
||||||
@@ -217,6 +304,62 @@ def analyze(name: str | None, analyze_all: bool, limit: int):
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ── ask ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@main.command()
|
||||||
|
@click.argument("question")
|
||||||
|
@click.option("--top", "-n", default=5, help="Number of source drafts to use")
|
||||||
|
@click.option("--cheap/--quality", default=True, help="Use Haiku (cheap) vs Sonnet (quality)")
|
||||||
|
def ask(question: str, top: int, cheap: bool):
|
||||||
|
"""Ask a natural language question about the drafts.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
ietf ask "Which drafts address agent authentication?"
|
||||||
|
ietf ask "What are the competing approaches to agent delegation?" --top 10
|
||||||
|
ietf ask "How do safety mechanisms work?" --cheap
|
||||||
|
"""
|
||||||
|
from .search import HybridSearch
|
||||||
|
|
||||||
|
cfg = _get_config()
|
||||||
|
db = Database(cfg)
|
||||||
|
|
||||||
|
try:
|
||||||
|
searcher = HybridSearch(cfg, db)
|
||||||
|
console.print(f"\n[dim]Searching for relevant drafts...[/]")
|
||||||
|
result = searcher.ask(question, top_k=top, cheap=cheap)
|
||||||
|
|
||||||
|
# Display the answer
|
||||||
|
console.print()
|
||||||
|
console.print("[bold cyan]Answer[/]")
|
||||||
|
console.print("[dim]" + "-" * 60 + "[/]")
|
||||||
|
console.print(result["answer"])
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
# Display source drafts table
|
||||||
|
if result["sources"]:
|
||||||
|
table = Table(title="Source Drafts")
|
||||||
|
table.add_column("#", style="dim", width=3)
|
||||||
|
table.add_column("Draft", style="cyan", max_width=50)
|
||||||
|
table.add_column("Title", max_width=45)
|
||||||
|
table.add_column("Match", width=10)
|
||||||
|
table.add_column("Score", justify="right", width=8)
|
||||||
|
|
||||||
|
for i, src in enumerate(result["sources"], 1):
|
||||||
|
score_str = f"{src['similarity']:.3f}" if src.get("similarity") else "-"
|
||||||
|
table.add_row(
|
||||||
|
str(i),
|
||||||
|
src["name"],
|
||||||
|
src["title"][:45],
|
||||||
|
src.get("match_type", ""),
|
||||||
|
score_str,
|
||||||
|
)
|
||||||
|
|
||||||
|
console.print(table)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
# ── compare ──────────────────────────────────────────────────────────────────
|
# ── compare ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -232,7 +375,12 @@ def compare(names: tuple[str, ...]):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
result = analyzer.compare_drafts(list(names))
|
result = analyzer.compare_drafts(list(names))
|
||||||
console.print(result)
|
if "error" in result:
|
||||||
|
console.print(f"[red]{result['error']}[/]")
|
||||||
|
else:
|
||||||
|
console.print(f"\n[bold cyan]Comparison of {len(result['drafts'])} drafts[/]")
|
||||||
|
console.print("[dim]" + "-" * 60 + "[/]")
|
||||||
|
console.print(result["text"])
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@@ -2107,7 +2255,8 @@ def draft_gen(gap_topic: str, output: str | None):
|
|||||||
|
|
||||||
@main.command("config")
|
@main.command("config")
|
||||||
@click.option("--set", "set_key", nargs=2, help="Set a config key (e.g. --set claude_model claude-opus-4-20250514)")
|
@click.option("--set", "set_key", nargs=2, help="Set a config key (e.g. --set claude_model claude-opus-4-20250514)")
|
||||||
def config_cmd(set_key: tuple[str, str] | None):
|
@click.option("--show", is_flag=True, help="Show effective config with env var sources noted")
|
||||||
|
def config_cmd(set_key: tuple[str, str] | None, show: bool):
|
||||||
"""Show or modify configuration."""
|
"""Show or modify configuration."""
|
||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
cfg = _get_config()
|
cfg = _get_config()
|
||||||
@@ -2131,8 +2280,20 @@ def config_cmd(set_key: tuple[str, str] | None):
|
|||||||
console.print(f"[red]Unknown config key: {key}[/]")
|
console.print(f"[red]Unknown config key: {key}[/]")
|
||||||
else:
|
else:
|
||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
|
env_sources = cfg.env_sources()
|
||||||
for key, val in asdict(cfg).items():
|
for key, val in asdict(cfg).items():
|
||||||
console.print(f" [bold]{key}:[/] {val}")
|
source_note = ""
|
||||||
|
if key in env_sources:
|
||||||
|
source_note = f" [yellow](from ${env_sources[key]})[/]"
|
||||||
|
console.print(f" [bold]{key}:[/] {val}{source_note}")
|
||||||
|
if env_sources:
|
||||||
|
console.print(f"\n [dim]({len(env_sources)} value(s) overridden by environment variables)[/]")
|
||||||
|
# Note about ANTHROPIC_API_KEY
|
||||||
|
import os
|
||||||
|
if os.environ.get("ANTHROPIC_API_KEY"):
|
||||||
|
console.print(" [dim]ANTHROPIC_API_KEY is set in environment[/]")
|
||||||
|
else:
|
||||||
|
console.print(" [dim]ANTHROPIC_API_KEY is NOT set in environment[/]")
|
||||||
|
|
||||||
|
|
||||||
# ── pipeline ────────────────────────────────────────────────────────────────
|
# ── pipeline ────────────────────────────────────────────────────────────────
|
||||||
@@ -2321,16 +2482,60 @@ def pipeline_quality(draft_id: int):
|
|||||||
|
|
||||||
@pipeline.command("status")
|
@pipeline.command("status")
|
||||||
def pipeline_status():
|
def pipeline_status():
|
||||||
"""Show all generated drafts."""
|
"""Show pipeline health: processing stages, generated drafts, and API cost."""
|
||||||
cfg = _get_config()
|
cfg = _get_config()
|
||||||
db = Database(cfg)
|
db = Database(cfg)
|
||||||
try:
|
try:
|
||||||
drafts = db.get_generated_drafts()
|
# Pipeline health overview
|
||||||
if not drafts:
|
total = db.count_drafts()
|
||||||
console.print("No generated drafts yet. Run `ietf pipeline generate <topic>`")
|
rated_count = len(db.drafts_with_ratings(limit=10000))
|
||||||
return
|
unrated = len(db.unrated_drafts(limit=10000))
|
||||||
|
unembedded = len(db.drafts_without_embeddings(limit=10000))
|
||||||
|
embedded_count = total - unembedded
|
||||||
|
no_ideas = len(db.drafts_without_ideas(limit=10000))
|
||||||
|
ideas_count = total - no_ideas
|
||||||
|
idea_total = db.idea_count()
|
||||||
|
gap_count = len(db.all_gaps())
|
||||||
|
input_tok, output_tok = db.total_tokens_used()
|
||||||
|
est_cost = (input_tok * 3.0 / 1_000_000) + (output_tok * 15.0 / 1_000_000)
|
||||||
|
|
||||||
table = Table(title=f"Generated Drafts ({len(drafts)})")
|
# Last update
|
||||||
|
snapshots = db.get_snapshots(limit=1)
|
||||||
|
last_update = snapshots[0]["snapshot_at"][:19] if snapshots else "never"
|
||||||
|
|
||||||
|
console.print("\n[bold]Pipeline Status[/]\n")
|
||||||
|
console.print(f" Total documents: [bold]{total}[/]")
|
||||||
|
console.print(f" Last update: {last_update}")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
# Stage table
|
||||||
|
stage_table = Table(title="Processing Stages")
|
||||||
|
stage_table.add_column("Stage", width=20)
|
||||||
|
stage_table.add_column("Done", justify="right", width=8)
|
||||||
|
stage_table.add_column("Missing", justify="right", width=8)
|
||||||
|
stage_table.add_column("Progress", width=20)
|
||||||
|
|
||||||
|
def bar(done, total_n):
|
||||||
|
pct = int(done / total_n * 100) if total_n > 0 else 0
|
||||||
|
filled = pct // 5
|
||||||
|
return f"[green]{'#' * filled}[/][dim]{'.' * (20 - filled)}[/] {pct}%"
|
||||||
|
|
||||||
|
stage_table.add_row("Rated", str(rated_count), str(unrated), bar(rated_count, total))
|
||||||
|
stage_table.add_row("Embedded", str(embedded_count), str(unembedded), bar(embedded_count, total))
|
||||||
|
stage_table.add_row("Ideas extracted", str(ideas_count), str(no_ideas), bar(ideas_count, total))
|
||||||
|
|
||||||
|
console.print(stage_table)
|
||||||
|
|
||||||
|
console.print(f"\n Total ideas: [bold]{idea_total}[/]")
|
||||||
|
console.print(f" Gaps identified: [bold]{gap_count}[/]")
|
||||||
|
console.print(f"\n API tokens: {input_tok:,} in + {output_tok:,} out")
|
||||||
|
console.print(f" Estimated cost: [bold]${est_cost:.2f}[/]")
|
||||||
|
|
||||||
|
# Generated drafts
|
||||||
|
gen_drafts = db.get_generated_drafts()
|
||||||
|
if gen_drafts:
|
||||||
|
console.print()
|
||||||
|
table = Table(title=f"Generated Drafts ({len(gen_drafts)})")
|
||||||
table.add_column("ID", justify="right", width=4)
|
table.add_column("ID", justify="right", width=4)
|
||||||
table.add_column("Draft Name", style="cyan")
|
table.add_column("Draft Name", style="cyan")
|
||||||
table.add_column("Gap Topic")
|
table.add_column("Gap Topic")
|
||||||
@@ -2339,7 +2544,7 @@ def pipeline_status():
|
|||||||
table.add_column("Quality", justify="right", width=7)
|
table.add_column("Quality", justify="right", width=7)
|
||||||
table.add_column("Created", width=10)
|
table.add_column("Created", width=10)
|
||||||
|
|
||||||
for d in drafts:
|
for d in gen_drafts:
|
||||||
table.add_row(
|
table.add_row(
|
||||||
str(d["id"]),
|
str(d["id"]),
|
||||||
d["draft_name"],
|
d["draft_name"],
|
||||||
@@ -2397,28 +2602,38 @@ def observatory():
|
|||||||
@observatory.command("update")
|
@observatory.command("update")
|
||||||
@click.option("--source", "-s", default=None, help="Comma-separated sources (e.g. ietf,w3c)")
|
@click.option("--source", "-s", default=None, help="Comma-separated sources (e.g. ietf,w3c)")
|
||||||
@click.option("--full/--delta", default=False, help="Full refresh or delta only")
|
@click.option("--full/--delta", default=False, help="Full refresh or delta only")
|
||||||
def observatory_update(source: str | None, full: bool):
|
@click.option("--dry-run", is_flag=True, default=False, help="Show what would happen without making changes")
|
||||||
|
def observatory_update(source: str | None, full: bool, dry_run: bool):
|
||||||
"""Fetch, analyze, and update the observatory."""
|
"""Fetch, analyze, and update the observatory."""
|
||||||
from .observatory import Observatory
|
from .observatory import Observatory
|
||||||
from .analyzer import Analyzer
|
|
||||||
|
|
||||||
cfg = _get_config()
|
cfg = _get_config()
|
||||||
db = Database(cfg)
|
db = Database(cfg)
|
||||||
analyzer = Analyzer(cfg, db)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if dry_run:
|
||||||
|
obs = Observatory(cfg, db)
|
||||||
|
else:
|
||||||
|
from .analyzer import Analyzer
|
||||||
|
analyzer = Analyzer(cfg, db)
|
||||||
obs = Observatory(cfg, db, analyzer)
|
obs = Observatory(cfg, db, analyzer)
|
||||||
sources = source.split(",") if source else None
|
sources = source.split(",") if source else None
|
||||||
console.print(f"[bold]Observatory update[/] ({'full' if full else 'delta'})")
|
mode = "full" if full else "delta"
|
||||||
result = obs.update(sources=sources, full=full)
|
console.print(f"[bold]Observatory update[/] ({mode}{' [DRY RUN]' if dry_run else ''})")
|
||||||
|
result = obs.update(sources=sources, full=full, dry_run=dry_run)
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
console.print(f"\n[bold green]Update complete![/]")
|
console.print(f"\n[bold green]Update complete![/]")
|
||||||
console.print(f" New docs: {result.get('new_docs', 0)}")
|
console.print(f" New docs: {result.get('new_docs', 0)}")
|
||||||
console.print(f" Analyzed: {result.get('analyzed', 0)}")
|
console.print(f" Analyzed: {result.get('analyzed', 0)}")
|
||||||
console.print(f" Embedded: {result.get('embedded', 0)}")
|
console.print(f" Embedded: {result.get('embedded', 0)}")
|
||||||
console.print(f" Ideas extracted: {result.get('ideas', 0)}")
|
console.print(f" Ideas extracted: {result.get('ideas', 0)}")
|
||||||
if result.get("gaps_updated"):
|
if result.get("gaps_changed"):
|
||||||
console.print(f" Gaps re-analyzed: yes ({result.get('gap_count', 0)} gaps)")
|
console.print(f" Gaps re-analyzed: yes")
|
||||||
|
if result.get("errors"):
|
||||||
|
console.print(f"\n [yellow]Errors ({len(result['errors'])}):[/]")
|
||||||
|
for err in result["errors"]:
|
||||||
|
console.print(f" - {err}")
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@@ -2676,3 +2891,105 @@ def monitor_status():
|
|||||||
console.print(table)
|
console.print(table)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ── export ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@main.command()
|
||||||
|
@click.option("--type", "export_type", type=click.Choice(["drafts", "ideas", "gaps", "authors", "ratings"]),
|
||||||
|
required=True, help="Type of data to export")
|
||||||
|
@click.option("--format", "fmt", type=click.Choice(["json", "csv"]), default="json", help="Output format")
|
||||||
|
@click.option("--output", "-o", "output_file", type=click.Path(), default=None,
|
||||||
|
help="Output file (default: stdout)")
|
||||||
|
def export(export_type: str, fmt: str, output_file: str | None):
|
||||||
|
"""Export data as JSON or CSV."""
|
||||||
|
import csv as csv_mod
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
|
||||||
|
cfg = _get_config()
|
||||||
|
db = Database(cfg)
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows: list[dict] = []
|
||||||
|
|
||||||
|
if export_type == "drafts":
|
||||||
|
drafts = db.list_drafts(limit=10000, order_by="name ASC")
|
||||||
|
for d in drafts:
|
||||||
|
rating = db.get_rating(d.name)
|
||||||
|
row = {
|
||||||
|
"name": d.name,
|
||||||
|
"title": d.title,
|
||||||
|
"rev": d.rev,
|
||||||
|
"date": d.date,
|
||||||
|
"pages": d.pages or 0,
|
||||||
|
"group": d.group or "",
|
||||||
|
}
|
||||||
|
if rating:
|
||||||
|
row["score"] = round(rating.composite_score, 2)
|
||||||
|
row["novelty"] = rating.novelty
|
||||||
|
row["maturity"] = rating.maturity
|
||||||
|
row["overlap"] = rating.overlap
|
||||||
|
row["momentum"] = rating.momentum
|
||||||
|
row["relevance"] = rating.relevance
|
||||||
|
row["categories"] = json.dumps(rating.categories)
|
||||||
|
row["summary"] = rating.summary
|
||||||
|
rows.append(row)
|
||||||
|
|
||||||
|
elif export_type == "ideas":
|
||||||
|
ideas = db.all_ideas()
|
||||||
|
rows = ideas
|
||||||
|
|
||||||
|
elif export_type == "gaps":
|
||||||
|
gaps = db.all_gaps()
|
||||||
|
rows = gaps
|
||||||
|
|
||||||
|
elif export_type == "authors":
|
||||||
|
top = db.top_authors(limit=10000)
|
||||||
|
for name, aff, cnt, drafts_list in top:
|
||||||
|
rows.append({
|
||||||
|
"name": name,
|
||||||
|
"affiliation": aff,
|
||||||
|
"draft_count": cnt,
|
||||||
|
"drafts": json.dumps(drafts_list),
|
||||||
|
})
|
||||||
|
|
||||||
|
elif export_type == "ratings":
|
||||||
|
pairs = db.drafts_with_ratings(limit=10000)
|
||||||
|
for draft, rating in pairs:
|
||||||
|
rows.append({
|
||||||
|
"name": draft.name,
|
||||||
|
"title": draft.title,
|
||||||
|
"score": round(rating.composite_score, 2),
|
||||||
|
"novelty": rating.novelty,
|
||||||
|
"maturity": rating.maturity,
|
||||||
|
"overlap": rating.overlap,
|
||||||
|
"momentum": rating.momentum,
|
||||||
|
"relevance": rating.relevance,
|
||||||
|
"categories": json.dumps(rating.categories),
|
||||||
|
"summary": rating.summary,
|
||||||
|
})
|
||||||
|
|
||||||
|
if fmt == "json":
|
||||||
|
text = json.dumps(rows, indent=2, ensure_ascii=False)
|
||||||
|
else:
|
||||||
|
# CSV
|
||||||
|
if not rows:
|
||||||
|
text = ""
|
||||||
|
else:
|
||||||
|
si = io.StringIO()
|
||||||
|
writer = csv_mod.DictWriter(si, fieldnames=rows[0].keys())
|
||||||
|
writer.writeheader()
|
||||||
|
for row in rows:
|
||||||
|
writer.writerow(row)
|
||||||
|
text = si.getvalue()
|
||||||
|
|
||||||
|
if output_file:
|
||||||
|
Path(output_file).write_text(text, encoding="utf-8")
|
||||||
|
console.print(f"Exported [bold green]{len(rows)}[/] {export_type} to [cyan]{output_file}[/] ({fmt})")
|
||||||
|
else:
|
||||||
|
click.echo(text)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from dataclasses import dataclass, field, asdict
|
from dataclasses import dataclass, field, asdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -24,6 +25,13 @@ DEFAULT_KEYWORDS = [
|
|||||||
"aipref",
|
"aipref",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Environment variable overrides (env var name -> config field name)
|
||||||
|
_ENV_OVERRIDES = {
|
||||||
|
"IETF_ANALYZER_DB_PATH": "db_path",
|
||||||
|
"IETF_ANALYZER_CLAUDE_MODEL": "claude_model",
|
||||||
|
"IETF_ANALYZER_OLLAMA_URL": "ollama_url",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Config:
|
class Config:
|
||||||
@@ -41,7 +49,9 @@ class Config:
|
|||||||
# Pipeline
|
# Pipeline
|
||||||
generation_max_tokens: int = 4096
|
generation_max_tokens: int = 4096
|
||||||
generation_model: str = "" # defaults to claude_model
|
generation_model: str = "" # defaults to claude_model
|
||||||
# Observatory
|
# Observatory — add "w3c" to enable W3C spec tracking:
|
||||||
|
# ietf observatory update --source w3c (one-off)
|
||||||
|
# or set observatory_sources to ["ietf", "w3c"] in config.json
|
||||||
observatory_sources: list[str] = field(default_factory=lambda: ["ietf"])
|
observatory_sources: list[str] = field(default_factory=lambda: ["ietf"])
|
||||||
dashboard_dir: str = str(DEFAULT_DATA_DIR.parent / "docs")
|
dashboard_dir: str = str(DEFAULT_DATA_DIR.parent / "docs")
|
||||||
w3c_groups: list[str] = field(default_factory=lambda: [
|
w3c_groups: list[str] = field(default_factory=lambda: [
|
||||||
@@ -52,9 +62,47 @@ class Config:
|
|||||||
Path(self.data_dir).mkdir(parents=True, exist_ok=True)
|
Path(self.data_dir).mkdir(parents=True, exist_ok=True)
|
||||||
CONFIG_FILE.write_text(json.dumps(asdict(self), indent=2))
|
CONFIG_FILE.write_text(json.dumps(asdict(self), indent=2))
|
||||||
|
|
||||||
|
def env_sources(self) -> dict[str, str]:
|
||||||
|
"""Return {field_name: env_var_name} for fields overridden by env vars."""
|
||||||
|
sources: dict[str, str] = {}
|
||||||
|
for env_var, field_name in _ENV_OVERRIDES.items():
|
||||||
|
if os.environ.get(env_var):
|
||||||
|
sources[field_name] = env_var
|
||||||
|
return sources
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _validate(cls, cfg: Config) -> None:
|
||||||
|
"""Validate config values, raise ValueError on problems."""
|
||||||
|
if not cfg.claude_model or not cfg.claude_model.strip():
|
||||||
|
raise ValueError(
|
||||||
|
"claude_model must be a non-empty string. "
|
||||||
|
"Check your config file or IETF_ANALYZER_CLAUDE_MODEL env var."
|
||||||
|
)
|
||||||
|
if not cfg.ollama_url.startswith(("http://", "https://")):
|
||||||
|
raise ValueError(
|
||||||
|
f"ollama_url must be an HTTP(S) URL, got: '{cfg.ollama_url}'. "
|
||||||
|
"Check your config file or IETF_ANALYZER_OLLAMA_URL env var."
|
||||||
|
)
|
||||||
|
db_parent = Path(cfg.db_path).parent
|
||||||
|
if not db_parent.exists():
|
||||||
|
raise ValueError(
|
||||||
|
f"db_path parent directory does not exist: '{db_parent}'. "
|
||||||
|
"Check your config file or IETF_ANALYZER_DB_PATH env var."
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def load(cls) -> Config:
|
def load(cls) -> Config:
|
||||||
if CONFIG_FILE.exists():
|
if CONFIG_FILE.exists():
|
||||||
data = json.loads(CONFIG_FILE.read_text())
|
data = json.loads(CONFIG_FILE.read_text())
|
||||||
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
|
cfg = cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
|
||||||
return cls()
|
else:
|
||||||
|
cfg = cls()
|
||||||
|
|
||||||
|
# Apply environment variable overrides (env vars take precedence)
|
||||||
|
for env_var, field_name in _ENV_OVERRIDES.items():
|
||||||
|
env_val = os.environ.get(env_var)
|
||||||
|
if env_val is not None:
|
||||||
|
setattr(cfg, field_name, env_val)
|
||||||
|
|
||||||
|
cls._validate(cfg)
|
||||||
|
return cfg
|
||||||
|
|||||||
@@ -192,6 +192,17 @@ CREATE TABLE IF NOT EXISTS gap_history (
|
|||||||
recorded_at TEXT
|
recorded_at TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Annotations (user notes + tags per draft)
|
||||||
|
CREATE TABLE IF NOT EXISTS annotations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
draft_name TEXT NOT NULL REFERENCES drafts(name),
|
||||||
|
note TEXT DEFAULT '',
|
||||||
|
tags TEXT DEFAULT '[]',
|
||||||
|
created_at TEXT,
|
||||||
|
updated_at TEXT,
|
||||||
|
UNIQUE(draft_name)
|
||||||
|
);
|
||||||
|
|
||||||
-- Monitor runs
|
-- Monitor runs
|
||||||
CREATE TABLE IF NOT EXISTS monitor_runs (
|
CREATE TABLE IF NOT EXISTS monitor_runs (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -529,14 +540,17 @@ class Database:
|
|||||||
ORDER BY da.author_order""",
|
ORDER BY da.author_order""",
|
||||||
(draft_name,),
|
(draft_name,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
cols = rows[0].keys() if rows else []
|
results = []
|
||||||
return [Author(
|
for r in rows:
|
||||||
person_id=r["person_id"], name=r["name"],
|
d = dict(r)
|
||||||
ascii_name=r["ascii_name"] if "ascii_name" in cols else "",
|
results.append(Author(
|
||||||
affiliation=r["affiliation"] if "affiliation" in cols else "",
|
person_id=d["person_id"], name=d["name"],
|
||||||
resource_uri=r["resource_uri"] if "resource_uri" in cols else "",
|
ascii_name=d.get("ascii_name", ""),
|
||||||
fetched_at=r["fetched_at"] if "fetched_at" in cols else None,
|
affiliation=d.get("affiliation", ""),
|
||||||
) for r in rows]
|
resource_uri=d.get("resource_uri", ""),
|
||||||
|
fetched_at=d.get("fetched_at"),
|
||||||
|
))
|
||||||
|
return results
|
||||||
|
|
||||||
def drafts_without_authors(self, limit: int = 500) -> list[str]:
|
def drafts_without_authors(self, limit: int = 500) -> list[str]:
|
||||||
rows = self.conn.execute(
|
rows = self.conn.execute(
|
||||||
@@ -681,7 +695,8 @@ class Database:
|
|||||||
"SELECT * FROM ideas WHERE draft_name = ?", (draft_name,)
|
"SELECT * FROM ideas WHERE draft_name = ?", (draft_name,)
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [{"id": r["id"], "title": r["title"], "description": r["description"],
|
return [{"id": r["id"], "title": r["title"], "description": r["description"],
|
||||||
"type": r["idea_type"], "draft_name": r["draft_name"]} for r in rows]
|
"type": r["idea_type"], "draft_name": r["draft_name"],
|
||||||
|
"novelty_score": r["novelty_score"]} for r in rows]
|
||||||
|
|
||||||
def delete_idea(self, idea_id: int) -> None:
|
def delete_idea(self, idea_id: int) -> None:
|
||||||
"""Delete a single idea and its embedding by ID."""
|
"""Delete a single idea and its embedding by ID."""
|
||||||
@@ -706,7 +721,8 @@ class Database:
|
|||||||
"SELECT * FROM ideas ORDER BY draft_name"
|
"SELECT * FROM ideas ORDER BY draft_name"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [{"title": r["title"], "description": r["description"],
|
return [{"title": r["title"], "description": r["description"],
|
||||||
"type": r["idea_type"], "draft_name": r["draft_name"]} for r in rows]
|
"type": r["idea_type"], "draft_name": r["draft_name"],
|
||||||
|
"novelty_score": r["novelty_score"]} for r in rows]
|
||||||
|
|
||||||
def idea_count(self) -> int:
|
def idea_count(self) -> int:
|
||||||
return self.conn.execute("SELECT COUNT(*) FROM ideas").fetchone()[0]
|
return self.conn.execute("SELECT COUNT(*) FROM ideas").fetchone()[0]
|
||||||
@@ -1380,6 +1396,75 @@ class Database:
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
# --- Annotations ---
|
||||||
|
|
||||||
|
def upsert_annotation(self, draft_name: str, note: str | None = None, tags: list[str] | None = None) -> None:
|
||||||
|
"""Insert or update an annotation for a draft."""
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
existing = self.conn.execute(
|
||||||
|
"SELECT id, note, tags FROM annotations WHERE draft_name = ?",
|
||||||
|
(draft_name,),
|
||||||
|
).fetchone()
|
||||||
|
if existing:
|
||||||
|
current_note = note if note is not None else existing["note"]
|
||||||
|
current_tags = tags if tags is not None else json.loads(existing["tags"] or "[]")
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE annotations SET note = ?, tags = ?, updated_at = ? WHERE draft_name = ?",
|
||||||
|
(current_note, json.dumps(current_tags), now, draft_name),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.conn.execute(
|
||||||
|
"""INSERT INTO annotations (draft_name, note, tags, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)""",
|
||||||
|
(draft_name, note or "", json.dumps(tags or []), now, now),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def get_annotation(self, draft_name: str) -> dict | None:
|
||||||
|
"""Return annotation for a draft, or None."""
|
||||||
|
row = self.conn.execute(
|
||||||
|
"SELECT * FROM annotations WHERE draft_name = ?", (draft_name,)
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"draft_name": row["draft_name"],
|
||||||
|
"note": row["note"],
|
||||||
|
"tags": json.loads(row["tags"] or "[]"),
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"updated_at": row["updated_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_all_annotations(self) -> list[dict]:
|
||||||
|
"""Return all annotations."""
|
||||||
|
rows = self.conn.execute(
|
||||||
|
"SELECT * FROM annotations ORDER BY updated_at DESC"
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r["id"],
|
||||||
|
"draft_name": r["draft_name"],
|
||||||
|
"note": r["note"],
|
||||||
|
"tags": json.loads(r["tags"] or "[]"),
|
||||||
|
"created_at": r["created_at"],
|
||||||
|
"updated_at": r["updated_at"],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
def search_by_tag(self, tag: str) -> list[str]:
|
||||||
|
"""Return draft names that have a specific tag in their annotation."""
|
||||||
|
rows = self.conn.execute(
|
||||||
|
"SELECT draft_name, tags FROM annotations"
|
||||||
|
).fetchall()
|
||||||
|
results = []
|
||||||
|
for r in rows:
|
||||||
|
tags = json.loads(r["tags"] or "[]")
|
||||||
|
if tag in tags:
|
||||||
|
results.append(r["draft_name"])
|
||||||
|
return results
|
||||||
|
|
||||||
# --- Helpers ---
|
# --- Helpers ---
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ class Observatory:
|
|||||||
self,
|
self,
|
||||||
sources: list[str] | None = None,
|
sources: list[str] | None = None,
|
||||||
full: bool = False,
|
full: bool = False,
|
||||||
|
dry_run: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Full update cycle.
|
"""Full update cycle.
|
||||||
|
|
||||||
@@ -87,10 +88,30 @@ class Observatory:
|
|||||||
6. Re-run gap analysis if >= 5 new docs
|
6. Re-run gap analysis if >= 5 new docs
|
||||||
7. Record gap changes in gap_history
|
7. Record gap changes in gap_history
|
||||||
8. Return summary stats
|
8. Return summary stats
|
||||||
|
|
||||||
|
If dry_run=True, show what would be fetched/analyzed without doing it.
|
||||||
"""
|
"""
|
||||||
sources = sources or self.config.observatory_sources
|
sources = sources or self.config.observatory_sources
|
||||||
stats: dict = {"sources": {}, "new_docs": 0, "analyzed": 0, "embedded": 0, "ideas": 0, "gaps_changed": False}
|
stats: dict = {"sources": {}, "new_docs": 0, "analyzed": 0, "embedded": 0, "ideas": 0, "gaps_changed": False}
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
console.print("[bold yellow]DRY RUN[/] — showing what would happen without making changes\n")
|
||||||
|
for src_name in sources:
|
||||||
|
console.print(f" Would fetch from: [cyan]{src_name}[/]")
|
||||||
|
src = self.db.get_source(src_name)
|
||||||
|
if src and src.get("last_fetch"):
|
||||||
|
console.print(f" Last fetch: {src['last_fetch'][:10]}")
|
||||||
|
else:
|
||||||
|
console.print(f" Last fetch: never (full fetch)")
|
||||||
|
unrated = len(self.db.unrated_drafts(limit=10000))
|
||||||
|
unembedded = len(self.db.drafts_without_embeddings(limit=10000))
|
||||||
|
no_ideas = len(self.db.drafts_without_ideas(limit=10000))
|
||||||
|
console.print(f"\n Would analyze: [bold]{unrated}[/] unrated documents")
|
||||||
|
console.print(f" Would embed: [bold]{unembedded}[/] documents")
|
||||||
|
console.print(f" Would extract ideas from: [bold]{no_ideas}[/] documents")
|
||||||
|
stats["dry_run"] = True
|
||||||
|
return stats
|
||||||
|
|
||||||
# 1. Snapshot current state
|
# 1. Snapshot current state
|
||||||
console.print("[bold]1/7[/] Creating snapshot...")
|
console.print("[bold]1/7[/] Creating snapshot...")
|
||||||
snapshot_id = self.db.create_snapshot()
|
snapshot_id = self.db.create_snapshot()
|
||||||
@@ -99,35 +120,58 @@ class Observatory:
|
|||||||
console.print("[bold]2/7[/] Fetching from sources...")
|
console.print("[bold]2/7[/] Fetching from sources...")
|
||||||
total_new = 0
|
total_new = 0
|
||||||
for src_name in sources:
|
for src_name in sources:
|
||||||
|
try:
|
||||||
new_count = self._fetch_source(src_name, full=full)
|
new_count = self._fetch_source(src_name, full=full)
|
||||||
stats["sources"][src_name] = new_count
|
stats["sources"][src_name] = new_count
|
||||||
total_new += new_count
|
total_new += new_count
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f" [red]Error fetching {src_name}: {e}[/]")
|
||||||
|
stats["sources"][src_name] = {"error": str(e)}
|
||||||
stats["new_docs"] = total_new
|
stats["new_docs"] = total_new
|
||||||
console.print(f" Fetched [bold green]{total_new}[/] new documents total")
|
console.print(f" Fetched [bold green]{total_new}[/] new documents total")
|
||||||
|
|
||||||
# 3. Analyze unrated docs
|
# 3. Analyze unrated docs
|
||||||
console.print("[bold]3/7[/] Analyzing unrated documents...")
|
console.print("[bold]3/7[/] Analyzing unrated documents...")
|
||||||
|
try:
|
||||||
analyzed = self.analyzer.rate_all_unrated(limit=200, batch_size=5)
|
analyzed = self.analyzer.rate_all_unrated(limit=200, batch_size=5)
|
||||||
stats["analyzed"] = analyzed
|
stats["analyzed"] = analyzed
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f" [red]Analysis failed: {e}[/]")
|
||||||
|
stats["analyzed"] = 0
|
||||||
|
stats["errors"] = stats.get("errors", []) + [f"analyze: {e}"]
|
||||||
|
|
||||||
# 4. Embed missing docs
|
# 4. Embed missing docs
|
||||||
console.print("[bold]4/7[/] Embedding missing documents...")
|
console.print("[bold]4/7[/] Embedding missing documents...")
|
||||||
|
try:
|
||||||
embedded = self._embed_missing()
|
embedded = self._embed_missing()
|
||||||
stats["embedded"] = embedded
|
stats["embedded"] = embedded
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f" [red]Embedding failed: {e}[/]")
|
||||||
|
stats["embedded"] = 0
|
||||||
|
stats["errors"] = stats.get("errors", []) + [f"embed: {e}"]
|
||||||
|
|
||||||
# 5. Extract ideas from new docs
|
# 5. Extract ideas from new docs
|
||||||
console.print("[bold]5/7[/] Extracting ideas...")
|
console.print("[bold]5/7[/] Extracting ideas...")
|
||||||
|
try:
|
||||||
ideas = self.analyzer.extract_all_ideas(limit=200, batch_size=5, cheap=True)
|
ideas = self.analyzer.extract_all_ideas(limit=200, batch_size=5, cheap=True)
|
||||||
stats["ideas"] = ideas
|
stats["ideas"] = ideas
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f" [red]Idea extraction failed: {e}[/]")
|
||||||
|
stats["ideas"] = 0
|
||||||
|
stats["errors"] = stats.get("errors", []) + [f"ideas: {e}"]
|
||||||
|
|
||||||
# 6. Re-run gap analysis if enough new docs
|
# 6. Re-run gap analysis if enough new docs
|
||||||
if total_new >= 5:
|
if total_new >= 5:
|
||||||
console.print("[bold]6/7[/] Re-running gap analysis...")
|
console.print("[bold]6/7[/] Re-running gap analysis...")
|
||||||
|
try:
|
||||||
gaps = self.analyzer.gap_analysis()
|
gaps = self.analyzer.gap_analysis()
|
||||||
if gaps:
|
if gaps:
|
||||||
self.db.record_gap_history(snapshot_id, gaps)
|
self.db.record_gap_history(snapshot_id, gaps)
|
||||||
stats["gaps_changed"] = True
|
stats["gaps_changed"] = True
|
||||||
console.print(f" Found [bold]{len(gaps)}[/] gaps")
|
console.print(f" Found [bold]{len(gaps)}[/] gaps")
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f" [red]Gap analysis failed: {e}[/]")
|
||||||
|
stats["errors"] = stats.get("errors", []) + [f"gaps: {e}"]
|
||||||
else:
|
else:
|
||||||
console.print(f"[bold]6/7[/] Skipping gap analysis ({total_new} < 5 new docs)")
|
console.print(f"[bold]6/7[/] Skipping gap analysis ({total_new} < 5 new docs)")
|
||||||
# Record current gaps unchanged
|
# Record current gaps unchanged
|
||||||
@@ -142,7 +186,7 @@ class Observatory:
|
|||||||
self.db.upsert_source(src_name, doc_count=count)
|
self.db.upsert_source(src_name, doc_count=count)
|
||||||
|
|
||||||
console.print("\n[bold green]Observatory update complete![/]")
|
console.print("\n[bold green]Observatory update complete![/]")
|
||||||
console.print(f" New docs: {total_new} | Analyzed: {analyzed} | Embedded: {embedded} | Ideas: {ideas}")
|
console.print(f" New docs: {total_new} | Analyzed: {stats['analyzed']} | Embedded: {stats['embedded']} | Ideas: {stats['ideas']}")
|
||||||
return stats
|
return stats
|
||||||
|
|
||||||
def _fetch_source(self, source_name: str, full: bool = False) -> int:
|
def _fetch_source(self, source_name: str, full: bool = False) -> int:
|
||||||
|
|||||||
102
src/ietf_analyzer/readiness.py
Normal file
102
src/ietf_analyzer/readiness.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"""Standards readiness scoring — composite 0-100 score predicting RFC proximity."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def compute_readiness(db, draft_name: str) -> dict:
|
||||||
|
"""Compute 0-100 readiness score with component breakdown.
|
||||||
|
|
||||||
|
Factors (each 0-1, weighted):
|
||||||
|
- wg_adopted (0.25): name starts with 'draft-ietf-' = 1.0, else 0.0
|
||||||
|
- revision_maturity (0.15): int(rev) normalized (0-5+ maps to 0-1)
|
||||||
|
- reference_density (0.15): len(draft_refs) / max_refs across corpus
|
||||||
|
- cited_by_count (0.15): how many OTHER drafts in corpus reference this one
|
||||||
|
- author_experience (0.15): avg number of drafts per author in draft_authors
|
||||||
|
- momentum_rating (0.15): momentum score from ratings (1-5 -> 0-1)
|
||||||
|
|
||||||
|
Returns {score: 0-100, factors: {name: {value, weight, contribution}}}
|
||||||
|
"""
|
||||||
|
draft = db.get_draft(draft_name)
|
||||||
|
if not draft:
|
||||||
|
return {"score": 0, "factors": {}}
|
||||||
|
|
||||||
|
factors = {}
|
||||||
|
|
||||||
|
# 1. WG Adopted (0.25)
|
||||||
|
wg_val = 1.0 if draft_name.startswith("draft-ietf-") else 0.0
|
||||||
|
factors["wg_adopted"] = {"value": wg_val, "weight": 0.25,
|
||||||
|
"label": "WG Adopted",
|
||||||
|
"detail": "draft-ietf-*" if wg_val else "individual"}
|
||||||
|
|
||||||
|
# 2. Revision Maturity (0.15)
|
||||||
|
try:
|
||||||
|
rev_num = int(draft.rev) if draft.rev else 0
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
rev_num = 0
|
||||||
|
rev_val = min(rev_num / 5.0, 1.0)
|
||||||
|
factors["revision_maturity"] = {"value": round(rev_val, 3), "weight": 0.15,
|
||||||
|
"label": "Revision Maturity",
|
||||||
|
"detail": f"rev {rev_num}"}
|
||||||
|
|
||||||
|
# 3. Reference Density (0.15)
|
||||||
|
refs = db.get_refs_for_draft(draft_name)
|
||||||
|
ref_count = len(refs)
|
||||||
|
# Get max refs across corpus
|
||||||
|
max_refs = db.conn.execute(
|
||||||
|
"SELECT MAX(cnt) FROM (SELECT COUNT(*) as cnt FROM draft_refs GROUP BY draft_name)"
|
||||||
|
).fetchone()[0] or 1
|
||||||
|
ref_val = min(ref_count / max_refs, 1.0)
|
||||||
|
factors["reference_density"] = {"value": round(ref_val, 3), "weight": 0.15,
|
||||||
|
"label": "Reference Density",
|
||||||
|
"detail": f"{ref_count} refs (max {max_refs})"}
|
||||||
|
|
||||||
|
# 4. Cited By Count (0.15)
|
||||||
|
cited_by = db.conn.execute(
|
||||||
|
"SELECT COUNT(DISTINCT draft_name) FROM draft_refs WHERE ref_type = 'draft' AND ref_id = ?",
|
||||||
|
(draft_name,),
|
||||||
|
).fetchone()[0]
|
||||||
|
# Normalize: being cited by 5+ drafts = 1.0
|
||||||
|
cited_val = min(cited_by / 5.0, 1.0)
|
||||||
|
factors["cited_by_count"] = {"value": round(cited_val, 3), "weight": 0.15,
|
||||||
|
"label": "Cited By Others",
|
||||||
|
"detail": f"{cited_by} draft(s)"}
|
||||||
|
|
||||||
|
# 5. Author Experience (0.15)
|
||||||
|
authors = db.get_authors_for_draft(draft_name)
|
||||||
|
if authors:
|
||||||
|
author_draft_counts = []
|
||||||
|
for a in authors:
|
||||||
|
cnt = db.conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM draft_authors WHERE person_id = ?",
|
||||||
|
(a.person_id,),
|
||||||
|
).fetchone()[0]
|
||||||
|
author_draft_counts.append(cnt)
|
||||||
|
avg_exp = sum(author_draft_counts) / len(author_draft_counts)
|
||||||
|
# Normalize: avg 5+ drafts per author = 1.0
|
||||||
|
exp_val = min(avg_exp / 5.0, 1.0)
|
||||||
|
else:
|
||||||
|
exp_val = 0.0
|
||||||
|
avg_exp = 0
|
||||||
|
factors["author_experience"] = {"value": round(exp_val, 3), "weight": 0.15,
|
||||||
|
"label": "Author Experience",
|
||||||
|
"detail": f"avg {avg_exp:.1f} drafts/author"}
|
||||||
|
|
||||||
|
# 6. Momentum Rating (0.15)
|
||||||
|
rating = db.get_rating(draft_name)
|
||||||
|
if rating:
|
||||||
|
mom_val = (rating.momentum - 1) / 4.0 # 1-5 -> 0-1
|
||||||
|
else:
|
||||||
|
mom_val = 0.0
|
||||||
|
factors["momentum_rating"] = {"value": round(mom_val, 3), "weight": 0.15,
|
||||||
|
"label": "Momentum",
|
||||||
|
"detail": f"{rating.momentum}/5" if rating else "unrated"}
|
||||||
|
|
||||||
|
# Compute weighted score
|
||||||
|
total = sum(f["value"] * f["weight"] for f in factors.values())
|
||||||
|
score = round(total * 100, 1)
|
||||||
|
|
||||||
|
# Add contribution to each factor
|
||||||
|
for f in factors.values():
|
||||||
|
f["contribution"] = round(f["value"] * f["weight"] * 100, 1)
|
||||||
|
|
||||||
|
return {"score": score, "factors": factors}
|
||||||
280
src/ietf_analyzer/search.py
Normal file
280
src/ietf_analyzer/search.py
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
"""Hybrid search — FTS5 keyword + embedding similarity + Claude synthesis."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
from .config import Config
|
||||||
|
from .db import Database
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
ASK_PROMPT = """\
|
||||||
|
You are an expert on IETF Internet-Drafts related to AI agents and autonomous systems.
|
||||||
|
|
||||||
|
Based on the following IETF drafts, answer this question:
|
||||||
|
|
||||||
|
**{question}**
|
||||||
|
|
||||||
|
## Source Drafts
|
||||||
|
|
||||||
|
{sources_block}
|
||||||
|
|
||||||
|
Instructions:
|
||||||
|
- Answer concisely but thoroughly (3-8 sentences).
|
||||||
|
- Cite specific drafts by name (e.g. draft-xyz-...) when referencing their contributions.
|
||||||
|
- If the drafts don't contain enough information to answer, say so clearly.
|
||||||
|
- Focus on concrete technical mechanisms, not general observations."""
|
||||||
|
|
||||||
|
|
||||||
|
def _cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
|
||||||
|
dot = np.dot(a, b)
|
||||||
|
norm = np.linalg.norm(a) * np.linalg.norm(b)
|
||||||
|
if norm == 0:
|
||||||
|
return 0.0
|
||||||
|
return float(dot / norm)
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_hash(text: str) -> str:
|
||||||
|
return hashlib.sha256(text.encode()).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
class HybridSearch:
|
||||||
|
def __init__(self, config: Config, db: Database, embedder=None):
|
||||||
|
self.config = config
|
||||||
|
self.db = db
|
||||||
|
self._embedder = embedder
|
||||||
|
self._ollama_available: bool | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def embedder(self):
|
||||||
|
"""Lazy-load embedder to avoid import errors when Ollama is unavailable."""
|
||||||
|
if self._embedder is None:
|
||||||
|
try:
|
||||||
|
from .embeddings import Embedder
|
||||||
|
self._embedder = Embedder(self.config, self.db)
|
||||||
|
self._ollama_available = True
|
||||||
|
except Exception:
|
||||||
|
self._ollama_available = False
|
||||||
|
return self._embedder
|
||||||
|
|
||||||
|
def _check_ollama(self) -> bool:
|
||||||
|
"""Check if Ollama is available for embedding queries."""
|
||||||
|
if self._ollama_available is not None:
|
||||||
|
return self._ollama_available
|
||||||
|
try:
|
||||||
|
embedder = self.embedder
|
||||||
|
if embedder is None:
|
||||||
|
self._ollama_available = False
|
||||||
|
return False
|
||||||
|
# Try a tiny embedding to verify connectivity
|
||||||
|
embedder.embed_text("test")
|
||||||
|
self._ollama_available = True
|
||||||
|
except Exception:
|
||||||
|
self._ollama_available = False
|
||||||
|
return self._ollama_available
|
||||||
|
|
||||||
|
def search(self, query: str, top_k: int = 10) -> list[dict]:
|
||||||
|
"""Combine FTS5 keyword search + embedding similarity search.
|
||||||
|
|
||||||
|
Returns ranked list of {name, title, score, excerpt, match_type}.
|
||||||
|
Falls back to FTS5-only if Ollama is unavailable.
|
||||||
|
"""
|
||||||
|
fts_results = self._fts_search(query, limit=top_k * 2)
|
||||||
|
embed_results = self._embedding_search(query, limit=top_k * 2)
|
||||||
|
|
||||||
|
if embed_results:
|
||||||
|
merged = self._reciprocal_rank_fusion(fts_results, embed_results)
|
||||||
|
else:
|
||||||
|
merged = fts_results
|
||||||
|
|
||||||
|
return merged[:top_k]
|
||||||
|
|
||||||
|
def _fts_search(self, query: str, limit: int = 20) -> list[dict]:
|
||||||
|
"""Run FTS5 keyword search, return ranked results."""
|
||||||
|
try:
|
||||||
|
drafts = self.db.search_drafts(query, limit=limit)
|
||||||
|
except Exception:
|
||||||
|
# FTS5 can fail on certain query syntax; fallback to simpler search
|
||||||
|
# Try wrapping each word with quotes for literal matching
|
||||||
|
words = query.split()
|
||||||
|
safe_query = " OR ".join(f'"{w}"' for w in words if w.strip())
|
||||||
|
try:
|
||||||
|
drafts = self.db.search_drafts(safe_query, limit=limit)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for rank, draft in enumerate(drafts):
|
||||||
|
excerpt = draft.abstract[:200] if draft.abstract else ""
|
||||||
|
results.append({
|
||||||
|
"name": draft.name,
|
||||||
|
"title": draft.title,
|
||||||
|
"score": 0.0, # Will be set by fusion
|
||||||
|
"excerpt": excerpt,
|
||||||
|
"match_type": "keyword",
|
||||||
|
"rank": rank,
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _embedding_search(self, query: str, limit: int = 20) -> list[dict]:
|
||||||
|
"""Run embedding similarity search against all stored embeddings."""
|
||||||
|
if not self._check_ollama():
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
query_vec = self.embedder.embed_text(query)
|
||||||
|
except Exception:
|
||||||
|
self._ollama_available = False
|
||||||
|
return []
|
||||||
|
|
||||||
|
all_embeddings = self.db.all_embeddings()
|
||||||
|
if not all_embeddings:
|
||||||
|
return []
|
||||||
|
|
||||||
|
similarities: list[tuple[str, float]] = []
|
||||||
|
for name, vec in all_embeddings.items():
|
||||||
|
sim = _cosine_similarity(query_vec, vec)
|
||||||
|
similarities.append((name, sim))
|
||||||
|
|
||||||
|
similarities.sort(key=lambda x: x[1], reverse=True)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for rank, (name, sim) in enumerate(similarities[:limit]):
|
||||||
|
draft = self.db.get_draft(name)
|
||||||
|
if draft is None:
|
||||||
|
continue
|
||||||
|
excerpt = draft.abstract[:200] if draft.abstract else ""
|
||||||
|
results.append({
|
||||||
|
"name": name,
|
||||||
|
"title": draft.title if draft else name,
|
||||||
|
"score": round(sim, 4),
|
||||||
|
"excerpt": excerpt,
|
||||||
|
"match_type": "semantic",
|
||||||
|
"rank": rank,
|
||||||
|
"similarity": round(sim, 4),
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _reciprocal_rank_fusion(
|
||||||
|
self,
|
||||||
|
fts_results: list[dict],
|
||||||
|
embed_results: list[dict],
|
||||||
|
k: int = 60,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Merge two ranked lists using reciprocal rank fusion (RRF).
|
||||||
|
|
||||||
|
RRF score = sum(1 / (k + rank)) across all lists where the item appears.
|
||||||
|
"""
|
||||||
|
scores: dict[str, float] = defaultdict(float)
|
||||||
|
items: dict[str, dict] = {}
|
||||||
|
|
||||||
|
for result in fts_results:
|
||||||
|
name = result["name"]
|
||||||
|
scores[name] += 1.0 / (k + result["rank"])
|
||||||
|
if name not in items:
|
||||||
|
items[name] = result.copy()
|
||||||
|
items[name]["match_type"] = "keyword"
|
||||||
|
|
||||||
|
for result in embed_results:
|
||||||
|
name = result["name"]
|
||||||
|
scores[name] += 1.0 / (k + result["rank"])
|
||||||
|
if name in items:
|
||||||
|
items[name]["match_type"] = "both"
|
||||||
|
items[name]["similarity"] = result.get("similarity", 0)
|
||||||
|
else:
|
||||||
|
items[name] = result.copy()
|
||||||
|
|
||||||
|
# Sort by RRF score
|
||||||
|
ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for name, rrf_score in ranked:
|
||||||
|
item = items[name]
|
||||||
|
item["score"] = round(rrf_score, 4)
|
||||||
|
results.append(item)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def ask(self, question: str, top_k: int = 5, cheap: bool = True) -> dict:
|
||||||
|
"""Answer a natural language question using search + Claude synthesis.
|
||||||
|
|
||||||
|
Returns {answer: str, sources: [{name, title, similarity, excerpt}]}.
|
||||||
|
Caches Claude responses via llm_cache.
|
||||||
|
"""
|
||||||
|
search_results = self.search(question, top_k=top_k)
|
||||||
|
|
||||||
|
if not search_results:
|
||||||
|
return {
|
||||||
|
"answer": "No relevant drafts found for your question.",
|
||||||
|
"sources": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build context from top results
|
||||||
|
sources_block = ""
|
||||||
|
sources = []
|
||||||
|
for r in search_results:
|
||||||
|
draft = self.db.get_draft(r["name"])
|
||||||
|
if draft is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Title + abstract + first 500 chars of full text
|
||||||
|
text_preview = ""
|
||||||
|
if draft.full_text:
|
||||||
|
text_preview = draft.full_text[:500]
|
||||||
|
|
||||||
|
sources_block += f"\n---\n**{draft.name}** — {draft.title}\n"
|
||||||
|
sources_block += f"Abstract: {draft.abstract[:500]}\n"
|
||||||
|
if text_preview:
|
||||||
|
sources_block += f"Content excerpt: {text_preview}\n"
|
||||||
|
|
||||||
|
sources.append({
|
||||||
|
"name": draft.name,
|
||||||
|
"title": draft.title,
|
||||||
|
"similarity": r.get("similarity", r.get("score", 0)),
|
||||||
|
"excerpt": r.get("excerpt", ""),
|
||||||
|
"match_type": r.get("match_type", ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
prompt = ASK_PROMPT.format(
|
||||||
|
question=question,
|
||||||
|
sources_block=sources_block,
|
||||||
|
)
|
||||||
|
phash = _prompt_hash(prompt)
|
||||||
|
|
||||||
|
# Check cache
|
||||||
|
cached = self.db.get_cached_response("_ask_", phash)
|
||||||
|
if cached:
|
||||||
|
return {
|
||||||
|
"answer": cached,
|
||||||
|
"sources": sources,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Call Claude
|
||||||
|
try:
|
||||||
|
from .analyzer import Analyzer
|
||||||
|
analyzer = Analyzer(self.config, self.db)
|
||||||
|
text, in_tok, out_tok = analyzer._call_claude(
|
||||||
|
prompt, max_tokens=1024, cheap=cheap
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cache the response
|
||||||
|
self.db.cache_response(
|
||||||
|
"_ask_", phash,
|
||||||
|
self.config.claude_model_cheap if cheap else self.config.claude_model,
|
||||||
|
prompt, text, in_tok, out_tok,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"answer": text,
|
||||||
|
"sources": sources,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"answer": f"Error generating answer: {e}",
|
||||||
|
"sources": sources,
|
||||||
|
}
|
||||||
269
src/webui/app.py
269
src/webui/app.py
@@ -12,7 +12,11 @@ from pathlib import Path
|
|||||||
_project_root = Path(__file__).resolve().parent.parent.parent
|
_project_root = Path(__file__).resolve().parent.parent.parent
|
||||||
sys.path.insert(0, str(_project_root / "src"))
|
sys.path.insert(0, str(_project_root / "src"))
|
||||||
|
|
||||||
from flask import Flask, render_template, request, jsonify, abort, g
|
import csv
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
|
||||||
|
from flask import Flask, render_template, request, jsonify, abort, g, Response
|
||||||
|
|
||||||
from webui.data import (
|
from webui.data import (
|
||||||
get_db,
|
get_db,
|
||||||
@@ -39,6 +43,10 @@ from webui.data import (
|
|||||||
get_idea_clusters,
|
get_idea_clusters,
|
||||||
get_monitor_status,
|
get_monitor_status,
|
||||||
get_author_network_full,
|
get_author_network_full,
|
||||||
|
get_citation_graph,
|
||||||
|
get_comparison_data,
|
||||||
|
get_ask_data,
|
||||||
|
global_search,
|
||||||
)
|
)
|
||||||
|
|
||||||
app = Flask(
|
app = Flask(
|
||||||
@@ -91,6 +99,7 @@ def drafts():
|
|||||||
page = request.args.get("page", 1, type=int)
|
page = request.args.get("page", 1, type=int)
|
||||||
search = request.args.get("q", "")
|
search = request.args.get("q", "")
|
||||||
category = request.args.get("cat", "")
|
category = request.args.get("cat", "")
|
||||||
|
source = request.args.get("source", "")
|
||||||
min_score = request.args.get("min_score", 0.0, type=float)
|
min_score = request.args.get("min_score", 0.0, type=float)
|
||||||
sort = request.args.get("sort", "score")
|
sort = request.args.get("sort", "score")
|
||||||
sort_dir = request.args.get("dir", "desc")
|
sort_dir = request.args.get("dir", "desc")
|
||||||
@@ -103,6 +112,7 @@ def drafts():
|
|||||||
min_score=min_score,
|
min_score=min_score,
|
||||||
sort=sort,
|
sort=sort,
|
||||||
sort_dir=sort_dir,
|
sort_dir=sort_dir,
|
||||||
|
source=source,
|
||||||
)
|
)
|
||||||
categories = get_category_counts(db())
|
categories = get_category_counts(db())
|
||||||
return render_template(
|
return render_template(
|
||||||
@@ -111,6 +121,7 @@ def drafts():
|
|||||||
categories=categories,
|
categories=categories,
|
||||||
search=search,
|
search=search,
|
||||||
current_cat=category,
|
current_cat=category,
|
||||||
|
current_source=source,
|
||||||
min_score=min_score,
|
min_score=min_score,
|
||||||
sort=sort,
|
sort=sort,
|
||||||
sort_dir=sort_dir,
|
sort_dir=sort_dir,
|
||||||
@@ -272,6 +283,12 @@ def authors():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/citations")
|
||||||
|
def citations():
|
||||||
|
graph = get_citation_graph(db())
|
||||||
|
return render_template("citations.html", graph=graph)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/monitor")
|
@app.route("/monitor")
|
||||||
def monitor_page():
|
def monitor_page():
|
||||||
status = get_monitor_status(db())
|
status = get_monitor_status(db())
|
||||||
@@ -294,21 +311,121 @@ def datenschutz():
|
|||||||
return render_template("datenschutz.html")
|
return render_template("datenschutz.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/search")
|
||||||
|
def search():
|
||||||
|
q = request.args.get("q", "").strip()
|
||||||
|
results = global_search(db(), q) if q else {"drafts": [], "ideas": [], "authors": [], "gaps": []}
|
||||||
|
total = sum(len(v) for v in results.values())
|
||||||
|
return render_template("search_results.html", query=q, results=results, total=total)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/ask")
|
||||||
|
def ask_page():
|
||||||
|
question = request.args.get("q", "")
|
||||||
|
result = None
|
||||||
|
if question:
|
||||||
|
top_k = request.args.get("top", 5, type=int)
|
||||||
|
result = get_ask_data(db(), question, top_k=top_k)
|
||||||
|
return render_template("ask.html", question=question, result=result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/ask", methods=["POST"])
|
||||||
|
def api_ask():
|
||||||
|
"""Answer a question via hybrid search + Claude. Returns JSON."""
|
||||||
|
data = request.get_json(force=True, silent=True)
|
||||||
|
if not data or "question" not in data:
|
||||||
|
return jsonify({"error": "Missing 'question' in request body"}), 400
|
||||||
|
question = data["question"]
|
||||||
|
top_k = data.get("top_k", 5)
|
||||||
|
cheap = data.get("cheap", True)
|
||||||
|
result = get_ask_data(db(), question, top_k=top_k, cheap=cheap)
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/compare")
|
||||||
|
def compare_page():
|
||||||
|
draft_names = request.args.get("drafts", "")
|
||||||
|
names = [n.strip() for n in draft_names.split(",") if n.strip()] if draft_names else []
|
||||||
|
data = None
|
||||||
|
if len(names) >= 2:
|
||||||
|
data = get_comparison_data(db(), names)
|
||||||
|
return render_template("comparison.html", names=names, data=data)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/compare", methods=["POST"])
|
||||||
|
def api_compare():
|
||||||
|
"""Run Claude comparison for drafts. Returns JSON with comparison text."""
|
||||||
|
req_data = request.get_json(force=True, silent=True)
|
||||||
|
if not req_data or "drafts" not in req_data:
|
||||||
|
return jsonify({"error": "Missing 'drafts' in request body"}), 400
|
||||||
|
|
||||||
|
names = req_data["drafts"]
|
||||||
|
if len(names) < 2:
|
||||||
|
return jsonify({"error": "Need at least 2 drafts to compare"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ietf_analyzer.config import Config
|
||||||
|
from ietf_analyzer.analyzer import Analyzer
|
||||||
|
|
||||||
|
cfg = Config.load()
|
||||||
|
database = db()
|
||||||
|
analyzer = Analyzer(cfg, database)
|
||||||
|
result = analyzer.compare_drafts(names)
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
# --- API endpoints for AJAX (used by client-side charts) ---
|
# --- API endpoints for AJAX (used by client-side charts) ---
|
||||||
|
|
||||||
|
|
||||||
|
def _to_csv_response(rows: list[dict], filename: str = "export.csv") -> Response:
|
||||||
|
"""Convert a list of dicts to a CSV download response."""
|
||||||
|
if not rows:
|
||||||
|
return Response("", mimetype="text/csv",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename={filename}"})
|
||||||
|
si = io.StringIO()
|
||||||
|
writer = csv.DictWriter(si, fieldnames=rows[0].keys())
|
||||||
|
writer.writeheader()
|
||||||
|
for row in rows:
|
||||||
|
# Flatten any list/dict values to JSON strings
|
||||||
|
flat = {}
|
||||||
|
for k, v in row.items():
|
||||||
|
if isinstance(v, (list, dict)):
|
||||||
|
flat[k] = json.dumps(v)
|
||||||
|
else:
|
||||||
|
flat[k] = v
|
||||||
|
writer.writerow(flat)
|
||||||
|
return Response(si.getvalue(), mimetype="text/csv",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename={filename}"})
|
||||||
|
|
||||||
|
|
||||||
|
def _results_to_csv(results: dict) -> Response:
|
||||||
|
"""Convert global search results (multi-category) to a single CSV."""
|
||||||
|
rows = []
|
||||||
|
for category, items in results.items():
|
||||||
|
for item in items:
|
||||||
|
row = {"_category": category}
|
||||||
|
row.update(item)
|
||||||
|
rows.append(row)
|
||||||
|
return _to_csv_response(rows, "search_results.csv")
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/drafts")
|
@app.route("/api/drafts")
|
||||||
def api_drafts():
|
def api_drafts():
|
||||||
page = request.args.get("page", 1, type=int)
|
page = request.args.get("page", 1, type=int)
|
||||||
search = request.args.get("q", "")
|
search = request.args.get("q", "")
|
||||||
category = request.args.get("cat", "")
|
category = request.args.get("cat", "")
|
||||||
|
source = request.args.get("source", "")
|
||||||
min_score = request.args.get("min_score", 0.0, type=float)
|
min_score = request.args.get("min_score", 0.0, type=float)
|
||||||
sort = request.args.get("sort", "score")
|
sort = request.args.get("sort", "score")
|
||||||
sort_dir = request.args.get("dir", "desc")
|
sort_dir = request.args.get("dir", "desc")
|
||||||
return jsonify(
|
data = get_drafts_page(db(), page=page, search=search, category=category,
|
||||||
get_drafts_page(db(), page=page, search=search, category=category,
|
min_score=min_score, sort=sort, sort_dir=sort_dir,
|
||||||
min_score=min_score, sort=sort, sort_dir=sort_dir)
|
source=source)
|
||||||
)
|
if request.args.get("format") == "csv":
|
||||||
|
return _to_csv_response(data.get("drafts", []), "drafts.csv")
|
||||||
|
return jsonify(data)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/stats")
|
@app.route("/api/stats")
|
||||||
@@ -321,6 +438,148 @@ def api_author_network():
|
|||||||
return jsonify(get_author_network_full(db()))
|
return jsonify(get_author_network_full(db()))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/citations")
|
||||||
|
def api_citations():
|
||||||
|
min_refs = request.args.get("min_refs", 2, type=int)
|
||||||
|
return jsonify(get_citation_graph(db(), min_refs=min_refs))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/search")
|
||||||
|
def api_search():
|
||||||
|
q = request.args.get("q", "").strip()
|
||||||
|
results = global_search(db(), q) if q else {"drafts": [], "ideas": [], "authors": [], "gaps": []}
|
||||||
|
if request.args.get("format") == "csv":
|
||||||
|
return _results_to_csv(results)
|
||||||
|
return jsonify(results)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/ideas")
|
||||||
|
def api_ideas():
|
||||||
|
data = get_ideas_by_type(db())
|
||||||
|
if request.args.get("format") == "csv":
|
||||||
|
return _to_csv_response(data.get("ideas", []), "ideas.csv")
|
||||||
|
return jsonify(data)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/gaps")
|
||||||
|
def api_gaps():
|
||||||
|
data = get_all_gaps(db())
|
||||||
|
if request.args.get("format") == "csv":
|
||||||
|
return _to_csv_response(data, "gaps.csv")
|
||||||
|
return jsonify(data)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/gaps/<int:gap_id>")
|
||||||
|
def api_gap_detail(gap_id: int):
|
||||||
|
gap = get_gap_detail(db(), gap_id)
|
||||||
|
if not gap:
|
||||||
|
return jsonify({"error": "Gap not found"}), 404
|
||||||
|
return jsonify(gap)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/ratings")
|
||||||
|
def api_ratings():
|
||||||
|
data = get_rating_distributions(db())
|
||||||
|
if request.args.get("format") == "csv":
|
||||||
|
# Transpose columnar data to rows
|
||||||
|
rows = []
|
||||||
|
for i in range(len(data.get("names", []))):
|
||||||
|
rows.append({
|
||||||
|
"name": data["names"][i],
|
||||||
|
"score": data["scores"][i],
|
||||||
|
"novelty": data["novelty"][i],
|
||||||
|
"maturity": data["maturity"][i],
|
||||||
|
"overlap": data["overlap"][i],
|
||||||
|
"momentum": data["momentum"][i],
|
||||||
|
"relevance": data["relevance"][i],
|
||||||
|
"category": data["categories"][i],
|
||||||
|
})
|
||||||
|
return _to_csv_response(rows, "ratings.csv")
|
||||||
|
return jsonify(data)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/timeline")
|
||||||
|
def api_timeline():
|
||||||
|
data = get_timeline_data(db())
|
||||||
|
return jsonify(data)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/landscape")
|
||||||
|
def api_landscape():
|
||||||
|
data = get_landscape_tsne(db())
|
||||||
|
if request.args.get("format") == "csv":
|
||||||
|
return _to_csv_response(data, "landscape.csv")
|
||||||
|
return jsonify(data)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/similarity")
|
||||||
|
def api_similarity():
|
||||||
|
data = get_similarity_graph(db())
|
||||||
|
return jsonify(data)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/idea-clusters")
|
||||||
|
def api_idea_clusters():
|
||||||
|
data = get_idea_clusters(db())
|
||||||
|
return jsonify(data)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/monitor")
|
||||||
|
def api_monitor():
|
||||||
|
data = get_monitor_status(db())
|
||||||
|
return jsonify(data)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/drafts/<path:name>")
|
||||||
|
def api_draft_detail(name: str):
|
||||||
|
detail = get_draft_detail(db(), name)
|
||||||
|
if not detail:
|
||||||
|
return jsonify({"error": "Draft not found"}), 404
|
||||||
|
return jsonify(detail)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/categories")
|
||||||
|
def api_categories():
|
||||||
|
data = get_category_counts(db())
|
||||||
|
if request.args.get("format") == "csv":
|
||||||
|
rows = [{"category": k, "count": v} for k, v in data.items()]
|
||||||
|
return _to_csv_response(rows, "categories.csv")
|
||||||
|
return jsonify(data)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/drafts/<path:name>/annotate", methods=["POST"])
|
||||||
|
def api_annotate(name: str):
|
||||||
|
"""Add or update annotation for a draft."""
|
||||||
|
import json as _json
|
||||||
|
database = db()
|
||||||
|
draft = database.get_draft(name)
|
||||||
|
if not draft:
|
||||||
|
return jsonify({"error": "Draft not found"}), 404
|
||||||
|
|
||||||
|
data = request.get_json(force=True, silent=True)
|
||||||
|
if not data:
|
||||||
|
return jsonify({"error": "Invalid JSON body"}), 400
|
||||||
|
|
||||||
|
note = data.get("note")
|
||||||
|
tags = data.get("tags")
|
||||||
|
add_tag = data.get("add_tag")
|
||||||
|
remove_tag = data.get("remove_tag")
|
||||||
|
|
||||||
|
# Handle add/remove tag operations
|
||||||
|
if add_tag or remove_tag:
|
||||||
|
existing = database.get_annotation(name)
|
||||||
|
current_tags = existing["tags"] if existing else []
|
||||||
|
if add_tag and add_tag not in current_tags:
|
||||||
|
current_tags.append(add_tag)
|
||||||
|
if remove_tag and remove_tag in current_tags:
|
||||||
|
current_tags.remove(remove_tag)
|
||||||
|
tags = current_tags
|
||||||
|
|
||||||
|
database.upsert_annotation(name, note=note, tags=tags)
|
||||||
|
annotation = database.get_annotation(name)
|
||||||
|
return jsonify({"success": True, "annotation": annotation})
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
print("Starting IETF Draft Analyzer Dashboard on http://127.0.0.1:5000")
|
print("Starting IETF Draft Analyzer Dashboard on http://127.0.0.1:5000")
|
||||||
app.run(debug=True, host="127.0.0.1", port=5000)
|
app.run(debug=True, host="127.0.0.1", port=5000)
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ def get_drafts_page(
|
|||||||
min_score: float = 0.0,
|
min_score: float = 0.0,
|
||||||
sort: str = "score",
|
sort: str = "score",
|
||||||
sort_dir: str = "desc",
|
sort_dir: str = "desc",
|
||||||
|
source: str = "",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Return a paginated, filtered list of drafts with ratings.
|
"""Return a paginated, filtered list of drafts with ratings.
|
||||||
|
|
||||||
@@ -80,6 +81,8 @@ def get_drafts_page(
|
|||||||
continue
|
continue
|
||||||
if category and category not in rating.categories:
|
if category and category not in rating.categories:
|
||||||
continue
|
continue
|
||||||
|
if source and draft.source != source:
|
||||||
|
continue
|
||||||
if search:
|
if search:
|
||||||
haystack = f"{draft.name} {draft.title} {rating.summary}".lower()
|
haystack = f"{draft.name} {draft.title} {rating.summary}".lower()
|
||||||
if not all(w in haystack for w in search.lower().split()):
|
if not all(w in haystack for w in search.lower().split()):
|
||||||
@@ -96,6 +99,9 @@ def get_drafts_page(
|
|||||||
"relevance": lambda p: p[1].relevance,
|
"relevance": lambda p: p[1].relevance,
|
||||||
"overlap": lambda p: p[1].overlap,
|
"overlap": lambda p: p[1].overlap,
|
||||||
"momentum": lambda p: p[1].momentum,
|
"momentum": lambda p: p[1].momentum,
|
||||||
|
"readiness": lambda p: (1.0 if p[0].name.startswith("draft-ietf-") else 0.0) * 0.25 +
|
||||||
|
min(int(p[0].rev or "0") / 5.0, 1.0) * 0.15 +
|
||||||
|
((p[1].momentum - 1) / 4.0) * 0.15,
|
||||||
}
|
}
|
||||||
key_fn = sort_keys.get(sort, sort_keys["score"])
|
key_fn = sort_keys.get(sort, sort_keys["score"])
|
||||||
reverse = sort_dir == "desc"
|
reverse = sort_dir == "desc"
|
||||||
@@ -107,15 +113,23 @@ def get_drafts_page(
|
|||||||
start = (page - 1) * per_page
|
start = (page - 1) * per_page
|
||||||
page_items = filtered[start : start + per_page]
|
page_items = filtered[start : start + per_page]
|
||||||
|
|
||||||
|
# Pre-compute readiness for page items (lightweight version)
|
||||||
|
from ietf_analyzer.readiness import compute_readiness
|
||||||
|
readiness_cache = {}
|
||||||
|
for draft, rating in page_items:
|
||||||
|
readiness_cache[draft.name] = compute_readiness(db, draft.name)
|
||||||
|
|
||||||
drafts = []
|
drafts = []
|
||||||
for draft, rating in page_items:
|
for draft, rating in page_items:
|
||||||
|
r_score = readiness_cache.get(draft.name, {}).get("score", 0)
|
||||||
drafts.append({
|
drafts.append({
|
||||||
"name": draft.name,
|
"name": draft.name,
|
||||||
"title": draft.title,
|
"title": draft.title,
|
||||||
"date": draft.date,
|
"date": draft.date,
|
||||||
"url": draft.datatracker_url,
|
"url": draft.source_url if draft.source != "ietf" else draft.datatracker_url,
|
||||||
"pages": draft.pages or 0,
|
"pages": draft.pages or 0,
|
||||||
"group": draft.group or "individual",
|
"group": draft.group or "individual",
|
||||||
|
"source": draft.source or "ietf",
|
||||||
"score": round(rating.composite_score, 2),
|
"score": round(rating.composite_score, 2),
|
||||||
"novelty": rating.novelty,
|
"novelty": rating.novelty,
|
||||||
"maturity": rating.maturity,
|
"maturity": rating.maturity,
|
||||||
@@ -124,6 +138,7 @@ def get_drafts_page(
|
|||||||
"relevance": rating.relevance,
|
"relevance": rating.relevance,
|
||||||
"categories": rating.categories,
|
"categories": rating.categories,
|
||||||
"summary": rating.summary,
|
"summary": rating.summary,
|
||||||
|
"readiness": r_score,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -185,6 +200,14 @@ def get_draft_detail(db: Database, name: str) -> dict | None:
|
|||||||
"categories": rating.categories,
|
"categories": rating.categories,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Readiness score
|
||||||
|
from ietf_analyzer.readiness import compute_readiness
|
||||||
|
result["readiness"] = compute_readiness(db, name)
|
||||||
|
|
||||||
|
# Annotation
|
||||||
|
annotation = db.get_annotation(name)
|
||||||
|
result["annotation"] = annotation
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -253,8 +276,11 @@ def get_ideas_by_type(db: Database) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def get_all_gaps(db: Database) -> list[dict]:
|
def get_all_gaps(db: Database) -> list[dict]:
|
||||||
"""Return all gap analysis results."""
|
"""Return all gap analysis results, sorted by severity (critical first)."""
|
||||||
return db.all_gaps()
|
_sev_order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
|
||||||
|
gaps = db.all_gaps()
|
||||||
|
gaps.sort(key=lambda g: _sev_order.get(g.get("severity", "low"), 99))
|
||||||
|
return gaps
|
||||||
|
|
||||||
|
|
||||||
def get_gap_detail(db: Database, gap_id: int) -> dict | None:
|
def get_gap_detail(db: Database, gap_id: int) -> dict | None:
|
||||||
@@ -775,17 +801,252 @@ def get_monitor_status(db: Database) -> dict:
|
|||||||
"""Return monitoring status data for dashboard."""
|
"""Return monitoring status data for dashboard."""
|
||||||
runs = db.get_monitor_runs(limit=20)
|
runs = db.get_monitor_runs(limit=20)
|
||||||
last = runs[0] if runs else None
|
last = runs[0] if runs else None
|
||||||
|
total_drafts = db.count_drafts()
|
||||||
|
rated_count = len(db.drafts_with_ratings(limit=10000))
|
||||||
unrated = len(db.unrated_drafts(limit=9999))
|
unrated = len(db.unrated_drafts(limit=9999))
|
||||||
unembedded = len(db.drafts_without_embeddings(limit=9999))
|
unembedded = len(db.drafts_without_embeddings(limit=9999))
|
||||||
|
embedded_count = total_drafts - unembedded
|
||||||
no_ideas = len(db.drafts_without_ideas(limit=9999))
|
no_ideas = len(db.drafts_without_ideas(limit=9999))
|
||||||
|
ideas_count = total_drafts - no_ideas
|
||||||
|
idea_total = db.idea_count()
|
||||||
|
gap_count = len(db.all_gaps())
|
||||||
|
input_tok, output_tok = db.total_tokens_used()
|
||||||
|
|
||||||
|
# Estimate cost (Sonnet pricing: $3/M input, $15/M output)
|
||||||
|
est_cost = (input_tok * 3.0 / 1_000_000) + (output_tok * 15.0 / 1_000_000)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"last_run": last,
|
"last_run": last,
|
||||||
"runs": runs,
|
"runs": runs,
|
||||||
"unprocessed": {"unrated": unrated, "unembedded": unembedded, "no_ideas": no_ideas},
|
"unprocessed": {"unrated": unrated, "unembedded": unembedded, "no_ideas": no_ideas},
|
||||||
"total_runs": len(runs),
|
"total_runs": len(runs),
|
||||||
|
"pipeline": {
|
||||||
|
"total_drafts": total_drafts,
|
||||||
|
"rated": rated_count,
|
||||||
|
"embedded": embedded_count,
|
||||||
|
"with_ideas": ideas_count,
|
||||||
|
"idea_total": idea_total,
|
||||||
|
"gap_count": gap_count,
|
||||||
|
},
|
||||||
|
"cost": {
|
||||||
|
"input_tokens": input_tok,
|
||||||
|
"output_tokens": output_tok,
|
||||||
|
"estimated_usd": round(est_cost, 2),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_citation_graph(db: Database, min_refs: int = 2) -> dict:
|
||||||
|
"""Return citation network data for force-directed graph.
|
||||||
|
|
||||||
|
Returns {nodes: [{id, type, title, influence, ...}],
|
||||||
|
edges: [{source, target}],
|
||||||
|
stats: {node_count, edge_count, ...}}
|
||||||
|
"""
|
||||||
|
# Get all references
|
||||||
|
rows = db.conn.execute(
|
||||||
|
"SELECT draft_name, ref_type, ref_id FROM draft_refs"
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
# Count in-degree for each referenced item
|
||||||
|
in_degree: dict[str, int] = Counter()
|
||||||
|
edges_raw = []
|
||||||
|
for r in rows:
|
||||||
|
ref_key = f"{r['ref_type']}:{r['ref_id']}"
|
||||||
|
in_degree[ref_key] += 1
|
||||||
|
edges_raw.append((r["draft_name"], ref_key))
|
||||||
|
|
||||||
|
# Also count drafts as source nodes
|
||||||
|
draft_out: dict[str, int] = Counter()
|
||||||
|
for draft_name, _ in edges_raw:
|
||||||
|
draft_out[draft_name] += 1
|
||||||
|
|
||||||
|
# Get draft titles for labeling
|
||||||
|
draft_rows = db.conn.execute("SELECT name, title FROM drafts").fetchall()
|
||||||
|
draft_titles = {r["name"]: r["title"] for r in draft_rows}
|
||||||
|
|
||||||
|
# Get rating categories for draft coloring
|
||||||
|
rating_rows = db.conn.execute("SELECT draft_name, categories FROM ratings").fetchall()
|
||||||
|
draft_cats = {}
|
||||||
|
for r in rating_rows:
|
||||||
|
try:
|
||||||
|
cats = json.loads(r["categories"]) if r["categories"] else []
|
||||||
|
draft_cats[r["draft_name"]] = cats[0] if cats else "Other"
|
||||||
|
except Exception:
|
||||||
|
draft_cats[r["draft_name"]] = "Other"
|
||||||
|
|
||||||
|
# Filter: keep RFCs with min_refs+ references and all drafts that reference them
|
||||||
|
top_refs = {k: v for k, v in in_degree.items() if v >= min_refs}
|
||||||
|
|
||||||
|
# Build node set
|
||||||
|
node_set = set()
|
||||||
|
filtered_edges = []
|
||||||
|
for draft_name, ref_key in edges_raw:
|
||||||
|
if ref_key in top_refs:
|
||||||
|
node_set.add(draft_name)
|
||||||
|
node_set.add(ref_key)
|
||||||
|
filtered_edges.append({"source": draft_name, "target": ref_key})
|
||||||
|
|
||||||
|
# Limit to ~200 nodes max for readability
|
||||||
|
if len(node_set) > 250:
|
||||||
|
# Keep only refs with higher in-degree
|
||||||
|
sorted_refs = sorted(top_refs.items(), key=lambda x: x[1], reverse=True)
|
||||||
|
keep_refs = set(k for k, _ in sorted_refs[:80])
|
||||||
|
node_set = set()
|
||||||
|
filtered_edges = []
|
||||||
|
for draft_name, ref_key in edges_raw:
|
||||||
|
if ref_key in keep_refs:
|
||||||
|
node_set.add(draft_name)
|
||||||
|
node_set.add(ref_key)
|
||||||
|
filtered_edges.append({"source": draft_name, "target": ref_key})
|
||||||
|
|
||||||
|
# Build nodes
|
||||||
|
nodes = []
|
||||||
|
for nid in node_set:
|
||||||
|
if ":" in nid and not nid.startswith("draft-"):
|
||||||
|
# It's a reference node (rfc:1234, bcp:14, etc.)
|
||||||
|
ref_type, ref_id = nid.split(":", 1)
|
||||||
|
influence = in_degree.get(nid, 0)
|
||||||
|
if ref_type == "rfc":
|
||||||
|
try:
|
||||||
|
title = f"RFC {int(ref_id)}"
|
||||||
|
except ValueError:
|
||||||
|
title = f"RFC {ref_id}"
|
||||||
|
else:
|
||||||
|
title = f"{ref_type.upper()} {ref_id}"
|
||||||
|
nodes.append({
|
||||||
|
"id": nid,
|
||||||
|
"type": ref_type,
|
||||||
|
"title": title,
|
||||||
|
"influence": influence,
|
||||||
|
"ref_id": ref_id,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# It's a draft node
|
||||||
|
influence = in_degree.get(nid, 0) + draft_out.get(nid, 0)
|
||||||
|
nodes.append({
|
||||||
|
"id": nid,
|
||||||
|
"type": "draft",
|
||||||
|
"title": draft_titles.get(nid, nid),
|
||||||
|
"influence": draft_out.get(nid, 0),
|
||||||
|
"category": draft_cats.get(nid, "Other"),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Stats
|
||||||
|
rfc_count = sum(1 for n in nodes if n["type"] == "rfc")
|
||||||
|
draft_count = sum(1 for n in nodes if n["type"] == "draft")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"nodes": nodes,
|
||||||
|
"edges": filtered_edges,
|
||||||
|
"stats": {
|
||||||
|
"node_count": len(nodes),
|
||||||
|
"edge_count": len(filtered_edges),
|
||||||
|
"rfc_count": rfc_count,
|
||||||
|
"draft_count": draft_count,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def global_search(db: Database, query: str) -> dict:
|
||||||
|
"""Search across drafts (FTS5), ideas, authors, and gaps.
|
||||||
|
|
||||||
|
Returns {drafts: [...], ideas: [...], authors: [...], gaps: [...]}.
|
||||||
|
"""
|
||||||
|
results: dict = {"drafts": [], "ideas": [], "authors": [], "gaps": []}
|
||||||
|
if not query or not query.strip():
|
||||||
|
return results
|
||||||
|
|
||||||
|
q = query.strip()
|
||||||
|
|
||||||
|
# 1. Drafts via FTS5
|
||||||
|
try:
|
||||||
|
fts_query = " ".join(f'"{w}"' for w in q.split() if w)
|
||||||
|
rows = db.conn.execute(
|
||||||
|
"""SELECT d.name, d.title, d.abstract, d.time, d."group"
|
||||||
|
FROM drafts d
|
||||||
|
JOIN drafts_fts f ON d.rowid = f.rowid
|
||||||
|
WHERE drafts_fts MATCH ?
|
||||||
|
ORDER BY rank
|
||||||
|
LIMIT 50""",
|
||||||
|
(fts_query,),
|
||||||
|
).fetchall()
|
||||||
|
for r in rows:
|
||||||
|
results["drafts"].append({
|
||||||
|
"name": r["name"],
|
||||||
|
"title": r["title"],
|
||||||
|
"abstract": (r["abstract"] or "")[:200],
|
||||||
|
"date": r["time"],
|
||||||
|
"group": r["group"] or "individual",
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
# FTS5 match can fail on certain query syntax; fall back to LIKE
|
||||||
|
like = f"%{q}%"
|
||||||
|
rows = db.conn.execute(
|
||||||
|
"""SELECT name, title, abstract, time, "group" FROM drafts
|
||||||
|
WHERE title LIKE ? OR name LIKE ? OR abstract LIKE ?
|
||||||
|
LIMIT 50""",
|
||||||
|
(like, like, like),
|
||||||
|
).fetchall()
|
||||||
|
for r in rows:
|
||||||
|
results["drafts"].append({
|
||||||
|
"name": r["name"],
|
||||||
|
"title": r["title"],
|
||||||
|
"abstract": (r["abstract"] or "")[:200],
|
||||||
|
"date": r["time"],
|
||||||
|
"group": r["group"] or "individual",
|
||||||
|
})
|
||||||
|
|
||||||
|
# 2. Ideas via LIKE
|
||||||
|
like = f"%{q}%"
|
||||||
|
rows = db.conn.execute(
|
||||||
|
"""SELECT id, title, description, idea_type, draft_name FROM ideas
|
||||||
|
WHERE title LIKE ? OR description LIKE ?
|
||||||
|
ORDER BY id LIMIT 50""",
|
||||||
|
(like, like),
|
||||||
|
).fetchall()
|
||||||
|
for r in rows:
|
||||||
|
results["ideas"].append({
|
||||||
|
"id": r["id"],
|
||||||
|
"title": r["title"],
|
||||||
|
"description": (r["description"] or "")[:200],
|
||||||
|
"type": r["idea_type"],
|
||||||
|
"draft_name": r["draft_name"],
|
||||||
|
})
|
||||||
|
|
||||||
|
# 3. Authors via LIKE
|
||||||
|
rows = db.conn.execute(
|
||||||
|
"""SELECT person_id, name, affiliation FROM authors
|
||||||
|
WHERE name LIKE ? OR affiliation LIKE ?
|
||||||
|
ORDER BY name LIMIT 50""",
|
||||||
|
(like, like),
|
||||||
|
).fetchall()
|
||||||
|
for r in rows:
|
||||||
|
results["authors"].append({
|
||||||
|
"person_id": r["person_id"],
|
||||||
|
"name": r["name"],
|
||||||
|
"affiliation": r["affiliation"] or "",
|
||||||
|
})
|
||||||
|
|
||||||
|
# 4. Gaps via LIKE
|
||||||
|
rows = db.conn.execute(
|
||||||
|
"""SELECT id, topic, description, category, severity FROM gaps
|
||||||
|
WHERE topic LIKE ? OR description LIKE ?
|
||||||
|
ORDER BY id LIMIT 50""",
|
||||||
|
(like, like),
|
||||||
|
).fetchall()
|
||||||
|
for r in rows:
|
||||||
|
results["gaps"].append({
|
||||||
|
"id": r["id"],
|
||||||
|
"topic": r["topic"],
|
||||||
|
"description": (r["description"] or "")[:200],
|
||||||
|
"category": r["category"],
|
||||||
|
"severity": r["severity"],
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
def get_landscape_tsne(db: Database) -> list[dict]:
|
def get_landscape_tsne(db: Database) -> list[dict]:
|
||||||
"""Compute t-SNE from embeddings, return [{name, title, x, y, category, score}].
|
"""Compute t-SNE from embeddings, return [{name, title, x, y, category, score}].
|
||||||
|
|
||||||
@@ -829,3 +1090,116 @@ def get_landscape_tsne(db: Database) -> list[dict]:
|
|||||||
"score": round(r.composite_score, 2),
|
"score": round(r.composite_score, 2),
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_comparison_data(db: Database, names: list[str]) -> dict | None:
|
||||||
|
"""Get comparison data for a list of drafts.
|
||||||
|
|
||||||
|
Returns {
|
||||||
|
drafts: [{name, title, abstract, rating, ideas, refs, ...}],
|
||||||
|
shared_ideas: [{title, drafts: [name,...]}],
|
||||||
|
unique_ideas: {name: [{title, description}]},
|
||||||
|
shared_refs: [{type, id, drafts: [name,...]}],
|
||||||
|
unique_refs: {name: [{type, id}]},
|
||||||
|
similarities: [{a, b, similarity}],
|
||||||
|
comparison_text: str | None,
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
drafts_data = []
|
||||||
|
all_ideas: dict[str, list[dict]] = {}
|
||||||
|
all_refs: dict[str, list[tuple[str, str]]] = {}
|
||||||
|
|
||||||
|
for name in names:
|
||||||
|
detail = get_draft_detail(db, name)
|
||||||
|
if not detail:
|
||||||
|
continue
|
||||||
|
drafts_data.append(detail)
|
||||||
|
all_ideas[name] = detail.get("ideas", [])
|
||||||
|
all_refs[name] = [(r["type"], r["id"]) for r in detail.get("refs", [])]
|
||||||
|
|
||||||
|
if len(drafts_data) < 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Find shared vs unique ideas (by title similarity)
|
||||||
|
idea_title_drafts: dict[str, list[str]] = {}
|
||||||
|
for name, ideas in all_ideas.items():
|
||||||
|
for idea in ideas:
|
||||||
|
title_lower = idea["title"].lower().strip()
|
||||||
|
if title_lower not in idea_title_drafts:
|
||||||
|
idea_title_drafts[title_lower] = []
|
||||||
|
idea_title_drafts[title_lower].append(name)
|
||||||
|
|
||||||
|
shared_ideas = [
|
||||||
|
{"title": title, "drafts": draft_list}
|
||||||
|
for title, draft_list in idea_title_drafts.items()
|
||||||
|
if len(set(draft_list)) > 1
|
||||||
|
]
|
||||||
|
unique_ideas: dict[str, list[dict]] = {}
|
||||||
|
for name, ideas in all_ideas.items():
|
||||||
|
unique = []
|
||||||
|
for idea in ideas:
|
||||||
|
title_lower = idea["title"].lower().strip()
|
||||||
|
if len(set(idea_title_drafts.get(title_lower, []))) <= 1:
|
||||||
|
unique.append({"title": idea["title"], "description": idea.get("description", "")})
|
||||||
|
unique_ideas[name] = unique
|
||||||
|
|
||||||
|
# Find shared vs unique references
|
||||||
|
ref_drafts: dict[tuple[str, str], list[str]] = {}
|
||||||
|
for name, refs in all_refs.items():
|
||||||
|
for ref in refs:
|
||||||
|
if ref not in ref_drafts:
|
||||||
|
ref_drafts[ref] = []
|
||||||
|
ref_drafts[ref].append(name)
|
||||||
|
|
||||||
|
shared_refs = [
|
||||||
|
{"type": ref[0], "id": ref[1], "drafts": draft_list}
|
||||||
|
for ref, draft_list in ref_drafts.items()
|
||||||
|
if len(set(draft_list)) > 1
|
||||||
|
]
|
||||||
|
unique_refs: dict[str, list[dict]] = {}
|
||||||
|
for name, refs in all_refs.items():
|
||||||
|
unique = []
|
||||||
|
for ref in refs:
|
||||||
|
if len(set(ref_drafts.get(ref, []))) <= 1:
|
||||||
|
unique.append({"type": ref[0], "id": ref[1]})
|
||||||
|
unique_refs[name] = unique
|
||||||
|
|
||||||
|
# Pairwise embedding similarities
|
||||||
|
embeddings = db.all_embeddings()
|
||||||
|
similarities = []
|
||||||
|
valid_names = [d["name"] for d in drafts_data]
|
||||||
|
for i in range(len(valid_names)):
|
||||||
|
for j in range(i + 1, len(valid_names)):
|
||||||
|
a, b = valid_names[i], valid_names[j]
|
||||||
|
if a in embeddings and b in embeddings:
|
||||||
|
vec_a = embeddings[a]
|
||||||
|
vec_b = embeddings[b]
|
||||||
|
dot = np.dot(vec_a, vec_b)
|
||||||
|
norm = np.linalg.norm(vec_a) * np.linalg.norm(vec_b)
|
||||||
|
sim = float(dot / norm) if norm > 0 else 0.0
|
||||||
|
similarities.append({"a": a, "b": b, "similarity": round(sim, 4)})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"drafts": drafts_data,
|
||||||
|
"shared_ideas": shared_ideas,
|
||||||
|
"unique_ideas": unique_ideas,
|
||||||
|
"shared_refs": shared_refs,
|
||||||
|
"unique_refs": unique_refs,
|
||||||
|
"similarities": similarities,
|
||||||
|
"comparison_text": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_ask_data(db: Database, question: str, top_k: int = 5, cheap: bool = True) -> dict:
|
||||||
|
"""Run hybrid search + Claude synthesis for a question.
|
||||||
|
|
||||||
|
Returns {answer: str, sources: [{name, title, similarity, excerpt}]}.
|
||||||
|
"""
|
||||||
|
from ietf_analyzer.config import Config
|
||||||
|
from ietf_analyzer.search import HybridSearch
|
||||||
|
|
||||||
|
config = Config.load()
|
||||||
|
searcher = HybridSearch(config, db)
|
||||||
|
return searcher.ask(question, top_k=top_k, cheap=cheap)
|
||||||
|
|||||||
153
src/webui/templates/ask.html
Normal file
153
src/webui/templates/ask.html
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% set active_page = "ask" %}
|
||||||
|
|
||||||
|
{% block title %}Ask — IETF Draft Analyzer{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<style>
|
||||||
|
.ask-input {
|
||||||
|
background: linear-gradient(135deg, rgba(30, 41, 59, 0.8), rgba(30, 41, 59, 0.4));
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
.answer-card {
|
||||||
|
background: linear-gradient(135deg, rgba(30, 41, 59, 0.8), rgba(30, 41, 59, 0.4));
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
.source-row {
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.source-row:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.05);
|
||||||
|
}
|
||||||
|
.loading-spinner {
|
||||||
|
border: 3px solid rgba(59, 130, 246, 0.2);
|
||||||
|
border-top-color: #3b82f6;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-8 text-center">
|
||||||
|
<h1 class="text-3xl font-bold text-white">Ask the Draft Corpus</h1>
|
||||||
|
<p class="text-slate-400 text-sm mt-2">Ask natural language questions about IETF AI/agent drafts. Answers are synthesized from the most relevant documents.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Bar -->
|
||||||
|
<div class="max-w-3xl mx-auto mb-8">
|
||||||
|
<form method="get" action="/ask" id="askForm">
|
||||||
|
<div class="ask-input rounded-xl border border-slate-700 p-2 flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5 text-slate-500 ml-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
<input type="text" name="q" value="{{ question }}" placeholder="Which drafts address agent authentication? What approaches exist for agent delegation?"
|
||||||
|
class="flex-1 bg-transparent border-0 px-3 py-3 text-base text-slate-200 placeholder-slate-500 focus:outline-none"
|
||||||
|
autofocus>
|
||||||
|
<button type="submit" class="px-6 py-3 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-500 transition-colors flex-shrink-0">
|
||||||
|
Ask
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4 mt-3 px-2">
|
||||||
|
<label class="text-xs text-slate-500 flex items-center gap-1.5">
|
||||||
|
<span>Sources:</span>
|
||||||
|
<select name="top" class="bg-slate-800/60 border border-slate-700 rounded px-2 py-1 text-xs text-slate-300 focus:outline-none">
|
||||||
|
<option value="3" {% if request.args.get('top', '5') == '3' %}selected{% endif %}>3</option>
|
||||||
|
<option value="5" {% if request.args.get('top', '5') == '5' %}selected{% endif %}>5</option>
|
||||||
|
<option value="10" {% if request.args.get('top', '5') == '10' %}selected{% endif %}>10</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="text-xs text-slate-600">Combines keyword search + semantic similarity</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Example questions (show when no query) -->
|
||||||
|
{% if not question %}
|
||||||
|
<div class="max-w-3xl mx-auto">
|
||||||
|
<div class="text-xs text-slate-500 uppercase tracking-wide mb-3 font-medium">Example questions</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
|
{% set examples = [
|
||||||
|
"Which drafts address agent authentication and identity?",
|
||||||
|
"What are the competing approaches to agent-to-agent communication?",
|
||||||
|
"How do safety mechanisms work across different proposals?",
|
||||||
|
"What protocols exist for AI model serving and inference?",
|
||||||
|
"Which drafts propose agent discovery or registration systems?",
|
||||||
|
"What are the main gaps in autonomous network operations?",
|
||||||
|
] %}
|
||||||
|
{% for q in examples %}
|
||||||
|
<a href="/ask?q={{ q | urlencode }}" class="ask-input rounded-lg border border-slate-800 px-4 py-3 text-sm text-slate-400 hover:text-blue-400 hover:border-slate-700 transition">
|
||||||
|
{{ q }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Answer -->
|
||||||
|
{% if result %}
|
||||||
|
<div class="max-w-3xl mx-auto">
|
||||||
|
<!-- Synthesized answer -->
|
||||||
|
<div class="answer-card rounded-xl border border-slate-800 p-6 mb-6">
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
<svg class="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>
|
||||||
|
</svg>
|
||||||
|
<h2 class="text-lg font-semibold text-white">Answer</h2>
|
||||||
|
</div>
|
||||||
|
<div class="text-slate-300 text-sm leading-relaxed whitespace-pre-line">{{ result.answer }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Source drafts -->
|
||||||
|
{% if result.sources %}
|
||||||
|
<div class="answer-card rounded-xl border border-slate-800 overflow-hidden">
|
||||||
|
<div class="px-6 py-4 border-b border-slate-800">
|
||||||
|
<h3 class="text-sm font-semibold text-slate-300">Source Drafts ({{ result.sources|length }})</h3>
|
||||||
|
</div>
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-slate-800/50 bg-slate-900/40">
|
||||||
|
<th class="px-4 py-2.5 text-left text-xs font-medium text-slate-500 uppercase w-8">#</th>
|
||||||
|
<th class="px-4 py-2.5 text-left text-xs font-medium text-slate-500 uppercase">Draft</th>
|
||||||
|
<th class="px-4 py-2.5 text-left text-xs font-medium text-slate-500 uppercase w-20">Match</th>
|
||||||
|
<th class="px-4 py-2.5 text-right text-xs font-medium text-slate-500 uppercase w-16">Score</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-800/30">
|
||||||
|
{% for src in result.sources %}
|
||||||
|
<tr class="source-row">
|
||||||
|
<td class="px-4 py-3 text-slate-600">{{ loop.index }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<a href="/drafts/{{ src.name }}" class="text-blue-400 hover:text-blue-300 font-medium transition">
|
||||||
|
{{ src.title }}
|
||||||
|
</a>
|
||||||
|
<div class="text-xs text-slate-600 mt-0.5 font-mono">{{ src.name }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
{% if src.match_type == 'both' %}
|
||||||
|
<span class="inline-block px-2 py-0.5 rounded text-xs font-medium bg-green-900/30 text-green-400 border border-green-800/30">both</span>
|
||||||
|
{% elif src.match_type == 'semantic' %}
|
||||||
|
<span class="inline-block px-2 py-0.5 rounded text-xs font-medium bg-purple-900/30 text-purple-400 border border-purple-800/30">semantic</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="inline-block px-2 py-0.5 rounded text-xs font-medium bg-slate-800/50 text-slate-400 border border-slate-700/30">keyword</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right font-mono text-xs text-slate-400">
|
||||||
|
{{ "%.3f"|format(src.similarity) if src.similarity else "-" }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -82,11 +82,26 @@
|
|||||||
<h1 class="text-lg font-bold text-white tracking-tight">IETF Draft Analyzer</h1>
|
<h1 class="text-lg font-bold text-white tracking-tight">IETF Draft Analyzer</h1>
|
||||||
<p class="text-xs text-slate-500 mt-1">AI/Agent Standards Tracker</p>
|
<p class="text-xs text-slate-500 mt-1">AI/Agent Standards Tracker</p>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Global Search -->
|
||||||
|
<div class="px-4 pt-4 pb-2">
|
||||||
|
<form action="/search" method="get" class="relative">
|
||||||
|
<input type="text" name="q" placeholder="Search everything..."
|
||||||
|
value="{{ request.args.get('q', '') if request else '' }}"
|
||||||
|
class="w-full bg-slate-800/60 border border-slate-700 rounded-lg pl-9 pr-3 py-2 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition">
|
||||||
|
<svg class="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||||
|
</svg>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
<nav class="flex-1 py-4 overflow-y-auto">
|
<nav class="flex-1 py-4 overflow-y-auto">
|
||||||
<a href="/" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'overview' }}">
|
<a href="/" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'overview' }}">
|
||||||
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/></svg>
|
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/></svg>
|
||||||
Overview
|
Overview
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/ask" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'ask' }}">
|
||||||
|
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||||
|
Ask
|
||||||
|
</a>
|
||||||
<a href="/drafts" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'drafts' }}">
|
<a href="/drafts" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'drafts' }}">
|
||||||
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||||
Draft Explorer
|
Draft Explorer
|
||||||
@@ -119,6 +134,10 @@
|
|||||||
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/><circle cx="5" cy="6" r="1.5" fill="currentColor" stroke="none"/><circle cx="19" cy="18" r="1.5" fill="currentColor" stroke="none"/><circle cx="18" cy="6" r="1.5" fill="currentColor" stroke="none"/></svg>
|
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/><circle cx="5" cy="6" r="1.5" fill="currentColor" stroke="none"/><circle cx="19" cy="18" r="1.5" fill="currentColor" stroke="none"/><circle cx="18" cy="6" r="1.5" fill="currentColor" stroke="none"/></svg>
|
||||||
Similarity
|
Similarity
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/citations" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'citations' }}">
|
||||||
|
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/></svg>
|
||||||
|
Citations
|
||||||
|
</a>
|
||||||
<a href="/authors" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'authors' }}">
|
<a href="/authors" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'authors' }}">
|
||||||
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
||||||
Authors
|
Authors
|
||||||
|
|||||||
392
src/webui/templates/citations.html
Normal file
392
src/webui/templates/citations.html
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% set active_page = "citations" %}
|
||||||
|
|
||||||
|
{% block title %}Citation Graph — IETF Draft Analyzer{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<script src="/static/js/d3.v7.min.js"></script>
|
||||||
|
<style>
|
||||||
|
#citationSvg {
|
||||||
|
width: 100%;
|
||||||
|
height: 650px;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
#citationSvg:active { cursor: grabbing; }
|
||||||
|
#citationSvg .node { cursor: pointer; }
|
||||||
|
#citationSvg .node circle { stroke: rgba(255,255,255,0.15); stroke-width: 1.5px; transition: r 0.2s; }
|
||||||
|
#citationSvg .node:hover circle { stroke: #60a5fa; stroke-width: 2.5px; }
|
||||||
|
#citationSvg .node text { pointer-events: none; }
|
||||||
|
#citationSvg .link { stroke-opacity: 0.15; }
|
||||||
|
#citationSvg .link:hover { stroke-opacity: 0.5; }
|
||||||
|
.tooltip-card {
|
||||||
|
position: absolute; pointer-events: none; z-index: 50;
|
||||||
|
background: #1e293b; border: 1px solid #334155; border-radius: 8px;
|
||||||
|
padding: 10px 14px; font-size: 12px; color: #e2e8f0;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||||||
|
max-width: 320px; opacity: 0; transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.tooltip-card.visible { opacity: 1; }
|
||||||
|
.legend-swatch { width: 12px; height: 12px; border-radius: 3px; display: inline-block; }
|
||||||
|
.filter-btn { transition: all 0.15s; }
|
||||||
|
.filter-btn:hover { background: rgba(59, 130, 246, 0.2); }
|
||||||
|
.filter-btn.active { background: rgba(59, 130, 246, 0.3); border-color: #3b82f6; color: #60a5fa; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-white">Citation Graph</h1>
|
||||||
|
<p class="text-slate-400 text-sm mt-1">Cross-reference network: {{ graph.stats.draft_count }} drafts referencing {{ graph.stats.rfc_count }} RFCs</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary stats -->
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div class="stat-card rounded-xl border border-slate-800 p-4 relative overflow-hidden">
|
||||||
|
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-blue-500 to-blue-400"></div>
|
||||||
|
<div class="text-xs text-slate-500 uppercase tracking-wider">Drafts</div>
|
||||||
|
<div class="text-2xl font-bold text-white mt-1">{{ graph.stats.draft_count }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card rounded-xl border border-slate-800 p-4 relative overflow-hidden">
|
||||||
|
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-orange-500 to-orange-400"></div>
|
||||||
|
<div class="text-xs text-slate-500 uppercase tracking-wider">Referenced RFCs</div>
|
||||||
|
<div class="text-2xl font-bold text-white mt-1">{{ graph.stats.rfc_count }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card rounded-xl border border-slate-800 p-4 relative overflow-hidden">
|
||||||
|
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-emerald-500 to-emerald-400"></div>
|
||||||
|
<div class="text-xs text-slate-500 uppercase tracking-wider">Total Nodes</div>
|
||||||
|
<div class="text-2xl font-bold text-white mt-1">{{ graph.stats.node_count }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card rounded-xl border border-slate-800 p-4 relative overflow-hidden">
|
||||||
|
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-purple-500 to-purple-400"></div>
|
||||||
|
<div class="text-xs text-slate-500 uppercase tracking-wider">Citation Links</div>
|
||||||
|
<div class="text-2xl font-bold text-white mt-1">{{ graph.stats.edge_count }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- D3 Force-directed Citation Graph -->
|
||||||
|
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5 mb-6 relative">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-sm font-semibold text-slate-300">Cross-Reference Network</h2>
|
||||||
|
<p class="text-xs text-slate-500 mt-0.5">
|
||||||
|
<span class="inline-block w-2.5 h-2.5 rounded-full bg-blue-500 align-middle mr-1"></span>Drafts
|
||||||
|
<span class="inline-block w-2.5 h-2.5 rounded-full bg-orange-500 align-middle mr-1 ml-3"></span>RFCs
|
||||||
|
— Node size = influence (in-degree). Drag to rearrange. Scroll to zoom.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<button id="resetZoom" class="text-xs px-3 py-1.5 rounded-lg border border-slate-700 text-slate-400 hover:text-white hover:border-slate-500 transition">Reset View</button>
|
||||||
|
<select id="filterCategory" class="text-xs px-3 py-1.5 rounded-lg border border-slate-700 bg-slate-800 text-slate-300 focus:outline-none focus:border-blue-500">
|
||||||
|
<option value="">All Categories</option>
|
||||||
|
</select>
|
||||||
|
<label class="text-xs text-slate-500 ml-2">Min refs:</label>
|
||||||
|
<input type="range" id="minRefsSlider" min="1" max="15" value="2" class="w-20" style="accent-color: #3b82f6;">
|
||||||
|
<span id="minRefsVal" class="text-xs text-blue-400 font-mono w-4">2</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="relative">
|
||||||
|
<svg id="citationSvg"></svg>
|
||||||
|
<div id="tooltip" class="tooltip-card"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Top Referenced RFCs Table -->
|
||||||
|
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
|
||||||
|
<div class="p-4 border-b border-slate-800">
|
||||||
|
<h2 class="text-sm font-semibold text-slate-300">Most Referenced RFCs</h2>
|
||||||
|
<p class="text-xs text-slate-500 mt-0.5">RFCs cited by the most drafts in the corpus</p>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto max-h-[500px] overflow-y-auto">
|
||||||
|
<table class="w-full text-sm" id="rfcTable">
|
||||||
|
<thead class="sticky top-0 z-10">
|
||||||
|
<tr class="border-b border-slate-800 bg-slate-900">
|
||||||
|
<th class="px-4 py-2.5 text-left text-xs font-medium text-slate-400">#</th>
|
||||||
|
<th class="px-4 py-2.5 text-left text-xs font-medium text-slate-400">RFC</th>
|
||||||
|
<th class="px-4 py-2.5 text-right text-xs font-medium text-slate-400">Cited By</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-800/50" id="rfcBody">
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_scripts %}
|
||||||
|
<script>
|
||||||
|
const graph = {{ graph | tojson }};
|
||||||
|
|
||||||
|
const PALETTE = [
|
||||||
|
'#3b82f6', '#ef4444', '#22c55e', '#a855f7', '#f59e0b',
|
||||||
|
'#06b6d4', '#ec4899', '#84cc16', '#f97316', '#8b5cf6',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ===========================================================
|
||||||
|
// D3.js Force-Directed Citation Network
|
||||||
|
// ===========================================================
|
||||||
|
(function() {
|
||||||
|
if (graph.nodes.length === 0) {
|
||||||
|
document.getElementById('citationSvg').outerHTML =
|
||||||
|
'<p class="text-slate-500 text-sm text-center py-20">No citation data available</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const svg = d3.select('#citationSvg');
|
||||||
|
const container = svg.node().parentElement;
|
||||||
|
const width = container.clientWidth;
|
||||||
|
const height = 650;
|
||||||
|
svg.attr('viewBox', [0, 0, width, height]);
|
||||||
|
|
||||||
|
// Collect categories for filter dropdown
|
||||||
|
const categories = new Set();
|
||||||
|
graph.nodes.forEach(n => {
|
||||||
|
if (n.category && n.type === 'draft') categories.add(n.category);
|
||||||
|
});
|
||||||
|
const catSelect = document.getElementById('filterCategory');
|
||||||
|
[...categories].sort().forEach(cat => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = cat;
|
||||||
|
opt.textContent = cat;
|
||||||
|
catSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build RFC table
|
||||||
|
const rfcNodes = graph.nodes
|
||||||
|
.filter(n => n.type === 'rfc')
|
||||||
|
.sort((a, b) => b.influence - a.influence);
|
||||||
|
const rfcBody = document.getElementById('rfcBody');
|
||||||
|
rfcNodes.forEach((rfc, i) => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.className = 'hover:bg-slate-800/50 transition';
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td class="px-4 py-2.5 text-slate-500 text-xs">${i + 1}</td>
|
||||||
|
<td class="px-4 py-2.5">
|
||||||
|
<a href="https://www.rfc-editor.org/rfc/rfc${parseInt(rfc.ref_id)}" target="_blank" rel="noopener"
|
||||||
|
class="text-orange-400 hover:text-orange-300 transition font-medium text-sm">${rfc.title}</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2.5 text-right">
|
||||||
|
<span class="px-2 py-0.5 rounded-full text-xs font-medium
|
||||||
|
${rfc.influence >= 10 ? 'bg-orange-500/20 text-orange-400' :
|
||||||
|
rfc.influence >= 5 ? 'bg-blue-500/20 text-blue-400' :
|
||||||
|
'bg-slate-700/50 text-slate-400'}">
|
||||||
|
${rfc.influence}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
rfcBody.appendChild(tr);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prepare simulation data
|
||||||
|
const nodes = graph.nodes.map(n => ({...n}));
|
||||||
|
const links = graph.edges.map(e => ({source: e.source, target: e.target}));
|
||||||
|
|
||||||
|
// Size scale
|
||||||
|
const maxInfluence = d3.max(nodes, n => n.influence) || 1;
|
||||||
|
const rScale = d3.scaleSqrt().domain([0, maxInfluence]).range([3, 24]);
|
||||||
|
|
||||||
|
// Color: drafts = blue, rfcs = orange, others = amber
|
||||||
|
function nodeColor(n) {
|
||||||
|
if (n.type === 'rfc') return '#f59e0b';
|
||||||
|
if (n.type === 'bcp') return '#eab308';
|
||||||
|
return '#3b82f6';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force simulation
|
||||||
|
const simulation = d3.forceSimulation(nodes)
|
||||||
|
.force('link', d3.forceLink(links)
|
||||||
|
.id(d => d.id)
|
||||||
|
.distance(60)
|
||||||
|
.strength(0.15)
|
||||||
|
)
|
||||||
|
.force('charge', d3.forceManyBody().strength(-80).distanceMax(350))
|
||||||
|
.force('center', d3.forceCenter(width / 2, height / 2))
|
||||||
|
.force('collision', d3.forceCollide().radius(d => rScale(d.influence) + 2))
|
||||||
|
.force('x', d3.forceX(width / 2).strength(0.04))
|
||||||
|
.force('y', d3.forceY(height / 2).strength(0.04));
|
||||||
|
|
||||||
|
// Zoom behavior
|
||||||
|
const g = svg.append('g');
|
||||||
|
const zoom = d3.zoom()
|
||||||
|
.scaleExtent([0.15, 5])
|
||||||
|
.on('zoom', (event) => g.attr('transform', event.transform));
|
||||||
|
svg.call(zoom);
|
||||||
|
|
||||||
|
document.getElementById('resetZoom').addEventListener('click', () => {
|
||||||
|
svg.transition().duration(500).call(zoom.transform, d3.zoomIdentity);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Draw edges
|
||||||
|
const linkGroup = g.append('g').attr('class', 'links');
|
||||||
|
const link = linkGroup.selectAll('line')
|
||||||
|
.data(links)
|
||||||
|
.join('line')
|
||||||
|
.attr('class', 'link')
|
||||||
|
.attr('stroke', '#475569')
|
||||||
|
.attr('stroke-width', 0.8);
|
||||||
|
|
||||||
|
// Draw nodes
|
||||||
|
const nodeGroup = g.append('g').attr('class', 'nodes');
|
||||||
|
const node = nodeGroup.selectAll('g')
|
||||||
|
.data(nodes)
|
||||||
|
.join('g')
|
||||||
|
.attr('class', 'node')
|
||||||
|
.call(d3.drag()
|
||||||
|
.on('start', dragStarted)
|
||||||
|
.on('drag', dragged)
|
||||||
|
.on('end', dragEnded)
|
||||||
|
);
|
||||||
|
|
||||||
|
node.append('circle')
|
||||||
|
.attr('r', d => rScale(d.influence))
|
||||||
|
.attr('fill', d => nodeColor(d))
|
||||||
|
.attr('opacity', 0.85);
|
||||||
|
|
||||||
|
// Labels for high-influence nodes
|
||||||
|
node.filter(d => d.influence >= 5)
|
||||||
|
.append('text')
|
||||||
|
.text(d => {
|
||||||
|
if (d.type === 'rfc') return d.title;
|
||||||
|
const name = d.id.replace(/^draft-/, '');
|
||||||
|
return name.length > 20 ? name.slice(0, 18) + '..' : name;
|
||||||
|
})
|
||||||
|
.attr('dy', d => -(rScale(d.influence) + 4))
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.attr('fill', '#94a3b8')
|
||||||
|
.attr('font-size', '8px')
|
||||||
|
.attr('font-family', 'Inter, system-ui, sans-serif');
|
||||||
|
|
||||||
|
// Tooltip
|
||||||
|
const tooltip = document.getElementById('tooltip');
|
||||||
|
|
||||||
|
node.on('mouseover', function(event, d) {
|
||||||
|
const typeLabel = d.type === 'rfc' ? 'RFC' : d.type === 'bcp' ? 'BCP' : 'Draft';
|
||||||
|
const catLine = d.category ? `<div class="text-slate-500 text-xs mb-1">${d.category}</div>` : '';
|
||||||
|
tooltip.innerHTML = `
|
||||||
|
<div class="font-semibold text-white mb-1">${d.title}</div>
|
||||||
|
${catLine}
|
||||||
|
<div class="flex gap-4 text-xs">
|
||||||
|
<span class="text-slate-400">${typeLabel}</span>
|
||||||
|
<span><span class="${d.type === 'rfc' ? 'text-orange-400' : 'text-blue-400'} font-medium">${d.influence}</span> ${d.type === 'draft' ? 'outgoing refs' : 'citing drafts'}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
tooltip.classList.add('visible');
|
||||||
|
|
||||||
|
// Highlight connected nodes
|
||||||
|
const connected = new Set();
|
||||||
|
links.forEach(l => {
|
||||||
|
const sid = typeof l.source === 'object' ? l.source.id : l.source;
|
||||||
|
const tid = typeof l.target === 'object' ? l.target.id : l.target;
|
||||||
|
if (sid === d.id) connected.add(tid);
|
||||||
|
if (tid === d.id) connected.add(sid);
|
||||||
|
});
|
||||||
|
connected.add(d.id);
|
||||||
|
|
||||||
|
node.select('circle')
|
||||||
|
.attr('opacity', n => connected.has(n.id) ? 1 : 0.1);
|
||||||
|
node.selectAll('text')
|
||||||
|
.attr('opacity', n => connected.has(n.id) ? 1 : 0.1);
|
||||||
|
link
|
||||||
|
.attr('stroke-opacity', l => {
|
||||||
|
const sid = typeof l.source === 'object' ? l.source.id : l.source;
|
||||||
|
const tid = typeof l.target === 'object' ? l.target.id : l.target;
|
||||||
|
return (sid === d.id || tid === d.id) ? 0.6 : 0.02;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.on('mousemove', function(event) {
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
tooltip.style.left = (event.clientX - rect.left + 15) + 'px';
|
||||||
|
tooltip.style.top = (event.clientY - rect.top - 10) + 'px';
|
||||||
|
})
|
||||||
|
.on('mouseout', function() {
|
||||||
|
tooltip.classList.remove('visible');
|
||||||
|
node.select('circle').attr('opacity', 0.85);
|
||||||
|
node.selectAll('text').attr('opacity', 1);
|
||||||
|
link.attr('stroke-opacity', 0.15);
|
||||||
|
})
|
||||||
|
.on('click', function(event, d) {
|
||||||
|
if (d.type === 'rfc') {
|
||||||
|
window.open(`https://www.rfc-editor.org/rfc/rfc${parseInt(d.ref_id)}`, '_blank');
|
||||||
|
} else if (d.type === 'draft') {
|
||||||
|
window.open(`/drafts/${encodeURIComponent(d.id)}`, '_blank');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tick handler
|
||||||
|
simulation.on('tick', () => {
|
||||||
|
link
|
||||||
|
.attr('x1', d => d.source.x)
|
||||||
|
.attr('y1', d => d.source.y)
|
||||||
|
.attr('x2', d => d.target.x)
|
||||||
|
.attr('y2', d => d.target.y);
|
||||||
|
node.attr('transform', d => `translate(${d.x},${d.y})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drag handlers
|
||||||
|
function dragStarted(event, d) {
|
||||||
|
if (!event.active) simulation.alphaTarget(0.3).restart();
|
||||||
|
d.fx = d.x; d.fy = d.y;
|
||||||
|
}
|
||||||
|
function dragged(event, d) {
|
||||||
|
d.fx = event.x; d.fy = event.y;
|
||||||
|
}
|
||||||
|
function dragEnded(event, d) {
|
||||||
|
if (!event.active) simulation.alphaTarget(0);
|
||||||
|
d.fx = null; d.fy = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category filter
|
||||||
|
catSelect.addEventListener('change', function() {
|
||||||
|
const cat = this.value;
|
||||||
|
if (!cat) {
|
||||||
|
node.select('circle').attr('opacity', 0.85);
|
||||||
|
node.selectAll('text').attr('opacity', 1);
|
||||||
|
link.attr('stroke-opacity', 0.15);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const inCat = new Set();
|
||||||
|
nodes.forEach(n => {
|
||||||
|
if (n.type === 'draft' && n.category === cat) inCat.add(n.id);
|
||||||
|
});
|
||||||
|
// Also include RFCs referenced by those drafts
|
||||||
|
links.forEach(l => {
|
||||||
|
const sid = typeof l.source === 'object' ? l.source.id : l.source;
|
||||||
|
const tid = typeof l.target === 'object' ? l.target.id : l.target;
|
||||||
|
if (inCat.has(sid)) inCat.add(tid);
|
||||||
|
});
|
||||||
|
node.select('circle')
|
||||||
|
.attr('opacity', n => inCat.has(n.id) ? 1 : 0.05);
|
||||||
|
node.selectAll('text')
|
||||||
|
.attr('opacity', n => inCat.has(n.id) ? 1 : 0.05);
|
||||||
|
link.attr('stroke-opacity', l => {
|
||||||
|
const sid = typeof l.source === 'object' ? l.source.id : l.source;
|
||||||
|
return inCat.has(sid) ? 0.5 : 0.01;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Min refs slider (client-side filter)
|
||||||
|
const slider = document.getElementById('minRefsSlider');
|
||||||
|
const sliderVal = document.getElementById('minRefsVal');
|
||||||
|
slider.addEventListener('input', function() {
|
||||||
|
sliderVal.textContent = this.value;
|
||||||
|
const minR = parseInt(this.value);
|
||||||
|
// Show/hide RFC nodes by influence
|
||||||
|
node.select('circle')
|
||||||
|
.attr('opacity', n => {
|
||||||
|
if (n.type === 'draft') return 0.85;
|
||||||
|
return n.influence >= minR ? 0.85 : 0.05;
|
||||||
|
});
|
||||||
|
node.selectAll('text')
|
||||||
|
.attr('opacity', n => {
|
||||||
|
if (n.type === 'draft') return 1;
|
||||||
|
return n.influence >= minR ? 1 : 0.05;
|
||||||
|
});
|
||||||
|
// Filter edges
|
||||||
|
const visibleRfcs = new Set(nodes.filter(n => n.type !== 'draft' && n.influence >= minR).map(n => n.id));
|
||||||
|
link.attr('stroke-opacity', l => {
|
||||||
|
const tid = typeof l.target === 'object' ? l.target.id : l.target;
|
||||||
|
return visibleRfcs.has(tid) ? 0.15 : 0.01;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
220
src/webui/templates/comparison.html
Normal file
220
src/webui/templates/comparison.html
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% set active_page = "drafts" %}
|
||||||
|
|
||||||
|
{% block title %}Compare Drafts — IETF Draft Analyzer{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<style>
|
||||||
|
.compare-card {
|
||||||
|
background: linear-gradient(135deg, rgba(30, 41, 59, 0.8), rgba(30, 41, 59, 0.4));
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
.idea-shared { background: rgba(34, 197, 94, 0.1); border-color: rgba(34, 197, 94, 0.2); }
|
||||||
|
.idea-unique { background: rgba(59, 130, 246, 0.1); border-color: rgba(59, 130, 246, 0.2); }
|
||||||
|
.ref-pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 500;
|
||||||
|
background: rgba(51, 65, 85, 0.5);
|
||||||
|
color: #94a3b8;
|
||||||
|
border: 1px solid rgba(71, 85, 105, 0.4);
|
||||||
|
}
|
||||||
|
.loading-spinner {
|
||||||
|
border: 3px solid rgba(59, 130, 246, 0.2);
|
||||||
|
border-top-color: #3b82f6;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-white">Compare Drafts</h1>
|
||||||
|
<p class="text-slate-400 text-sm mt-1">Side-by-side analysis of selected drafts: shared ideas, references, and AI-generated comparison.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not data %}
|
||||||
|
<!-- No data yet — show instructions -->
|
||||||
|
<div class="compare-card rounded-xl border border-slate-800 p-8 text-center max-w-xl mx-auto">
|
||||||
|
<svg class="w-12 h-12 mx-auto mb-4 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
|
||||||
|
</svg>
|
||||||
|
{% if names and names|length < 2 %}
|
||||||
|
<p class="text-slate-400 text-sm mb-4">Need at least 2 valid draft names to compare.</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-slate-400 text-sm mb-4">Select drafts to compare from the <a href="/drafts" class="text-blue-400 hover:text-blue-300">Draft Explorer</a>, or enter draft names below.</p>
|
||||||
|
{% endif %}
|
||||||
|
<form method="get" action="/compare" class="mt-4">
|
||||||
|
<input type="text" name="drafts" placeholder="draft-name-1, draft-name-2, ..."
|
||||||
|
value="{{ names | join(', ') if names else '' }}"
|
||||||
|
class="w-full bg-slate-800/60 border border-slate-700 rounded-lg px-4 py-2.5 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:border-blue-500 mb-3">
|
||||||
|
<button type="submit" class="px-6 py-2.5 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-500 transition-colors">
|
||||||
|
Compare
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<!-- Draft cards side by side -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-{{ data.drafts|length }} gap-4 mb-6">
|
||||||
|
{% for draft in data.drafts %}
|
||||||
|
<div class="compare-card rounded-xl border border-slate-800 p-5">
|
||||||
|
<a href="/drafts/{{ draft.name }}" class="text-blue-400 hover:text-blue-300 font-semibold text-sm transition">
|
||||||
|
{{ draft.title }}
|
||||||
|
</a>
|
||||||
|
<div class="text-xs text-slate-600 font-mono mt-1">{{ draft.name }}</div>
|
||||||
|
<div class="text-xs text-slate-500 mt-2 line-clamp-3">{{ draft.abstract[:200] }}</div>
|
||||||
|
|
||||||
|
{% if draft.rating %}
|
||||||
|
<!-- Rating radar -->
|
||||||
|
<div class="mt-3 grid grid-cols-5 gap-1 text-center">
|
||||||
|
{% for dim, label in [('novelty', 'Nov'), ('maturity', 'Mat'), ('relevance', 'Rel'), ('momentum', 'Mom'), ('overlap', 'Ovl')] %}
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-slate-500">{{ label }}</div>
|
||||||
|
<div class="text-sm font-semibold {% if draft.rating[dim] >= 4 %}text-green-400{% elif draft.rating[dim] >= 3 %}text-yellow-400{% else %}text-red-400{% endif %}">
|
||||||
|
{{ draft.rating[dim] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="text-center mt-2">
|
||||||
|
<span class="score-badge {% if draft.rating.score >= 3.5 %}score-high{% elif draft.rating.score >= 2.5 %}score-mid{% else %}score-low{% endif %}">
|
||||||
|
{{ draft.rating.score }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pairwise similarities -->
|
||||||
|
{% if data.similarities %}
|
||||||
|
<div class="compare-card rounded-xl border border-slate-800 p-5 mb-6">
|
||||||
|
<h3 class="text-sm font-semibold text-slate-300 mb-3">Pairwise Embedding Similarity</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
{% for sim in data.similarities %}
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-xs font-mono text-slate-400 w-1/3 truncate">{{ sim.a.split('-')[-1][:20] }}</span>
|
||||||
|
<span class="text-xs text-slate-600">↔</span>
|
||||||
|
<span class="text-xs font-mono text-slate-400 w-1/3 truncate">{{ sim.b.split('-')[-1][:20] }}</span>
|
||||||
|
<div class="flex-1 h-2 bg-slate-800 rounded overflow-hidden">
|
||||||
|
<div class="h-full rounded {% if sim.similarity >= 0.85 %}bg-green-500{% elif sim.similarity >= 0.7 %}bg-yellow-500{% else %}bg-blue-500{% endif %}"
|
||||||
|
style="width: {{ (sim.similarity * 100)|int }}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs font-mono font-semibold w-12 text-right {% if sim.similarity >= 0.85 %}text-green-400{% elif sim.similarity >= 0.7 %}text-yellow-400{% else %}text-blue-400{% endif %}">
|
||||||
|
{{ "%.3f"|format(sim.similarity) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Shared Ideas -->
|
||||||
|
{% if data.shared_ideas %}
|
||||||
|
<div class="compare-card rounded-xl border border-slate-800 p-5 mb-6">
|
||||||
|
<h3 class="text-sm font-semibold text-green-400 mb-3">Shared Ideas ({{ data.shared_ideas|length }})</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
{% for idea in data.shared_ideas %}
|
||||||
|
<div class="idea-shared rounded-lg border p-3">
|
||||||
|
<div class="text-sm text-slate-200 font-medium">{{ idea.title }}</div>
|
||||||
|
<div class="text-xs text-slate-500 mt-1">Found in: {{ idea.drafts | join(', ') }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Unique Ideas per draft -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-{{ data.drafts|length }} gap-4 mb-6">
|
||||||
|
{% for draft in data.drafts %}
|
||||||
|
<div class="compare-card rounded-xl border border-slate-800 p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-blue-400 mb-3">
|
||||||
|
Unique Ideas: {{ draft.name.split('-')[-1][:20] }}
|
||||||
|
<span class="text-slate-600 font-normal">({{ data.unique_ideas.get(draft.name, [])|length }})</span>
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
{% for idea in data.unique_ideas.get(draft.name, [])[:10] %}
|
||||||
|
<div class="idea-unique rounded-lg border p-2.5">
|
||||||
|
<div class="text-xs text-slate-300 font-medium">{{ idea.title }}</div>
|
||||||
|
{% if idea.description %}
|
||||||
|
<div class="text-xs text-slate-500 mt-0.5 line-clamp-2">{{ idea.description }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% if data.unique_ideas.get(draft.name, [])|length == 0 %}
|
||||||
|
<div class="text-xs text-slate-600 italic">No unique ideas extracted</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Shared References -->
|
||||||
|
{% if data.shared_refs %}
|
||||||
|
<div class="compare-card rounded-xl border border-slate-800 p-5 mb-6">
|
||||||
|
<h3 class="text-sm font-semibold text-slate-300 mb-3">Shared References ({{ data.shared_refs|length }})</h3>
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
{% for ref in data.shared_refs %}
|
||||||
|
<span class="ref-pill">{{ ref.type|upper }} {{ ref.id }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Claude Comparison (lazy-loaded) -->
|
||||||
|
<div class="compare-card rounded-xl border border-slate-800 p-5 mb-6" id="comparisonSection">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h3 class="text-sm font-semibold text-slate-300">AI Comparison Summary</h3>
|
||||||
|
<button onclick="runComparison()" id="compareBtn"
|
||||||
|
class="px-4 py-1.5 bg-blue-600 text-white rounded-lg text-xs font-medium hover:bg-blue-500 transition-colors">
|
||||||
|
Generate Comparison
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="comparisonResult" class="text-sm text-slate-400">
|
||||||
|
Click "Generate Comparison" to get a Claude-powered analysis of these drafts.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_scripts %}
|
||||||
|
{% if data %}
|
||||||
|
<script>
|
||||||
|
async function runComparison() {
|
||||||
|
const btn = document.getElementById('compareBtn');
|
||||||
|
const result = document.getElementById('comparisonResult');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="loading-spinner"></span> Analyzing...';
|
||||||
|
result.innerHTML = '<div class="flex items-center gap-2"><span class="loading-spinner"></span> <span class="text-slate-500">Generating comparison...</span></div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/compare', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({drafts: {{ data.drafts | map(attribute='name') | list | tojson }}})
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.error) {
|
||||||
|
result.innerHTML = '<div class="text-red-400">Error: ' + data.error + '</div>';
|
||||||
|
} else {
|
||||||
|
result.innerHTML = '<div class="text-slate-300 whitespace-pre-line leading-relaxed">' + data.text + '</div>';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
result.innerHTML = '<div class="text-red-400">Error: ' + e.message + '</div>';
|
||||||
|
}
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Regenerate';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -156,6 +156,14 @@
|
|||||||
{{ idea.type }}
|
{{ idea.type }}
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if idea.novelty_score is not none and idea.novelty_score %}
|
||||||
|
<span class="flex-shrink-0 px-1.5 py-0.5 rounded text-[10px] font-mono
|
||||||
|
{% if idea.novelty_score >= 4 %}bg-green-500/20 text-green-400
|
||||||
|
{% elif idea.novelty_score >= 3 %}bg-amber-500/20 text-amber-400
|
||||||
|
{% elif idea.novelty_score >= 2 %}bg-orange-500/20 text-orange-400
|
||||||
|
{% else %}bg-red-500/20 text-red-400{% endif %}"
|
||||||
|
title="Novelty score">N:{{ idea.novelty_score }}</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if idea.description %}
|
{% if idea.description %}
|
||||||
<p class="text-xs text-slate-500 leading-relaxed mt-1">{{ idea.description }}</p>
|
<p class="text-xs text-slate-500 leading-relaxed mt-1">{{ idea.description }}</p>
|
||||||
@@ -165,6 +173,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Annotation (notes & tags) -->
|
||||||
|
<div class="detail-card rounded-xl border border-slate-800 p-6" id="annotationSection">
|
||||||
|
<h2 class="text-sm font-semibold text-slate-300 mb-3 flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||||
|
Notes & Tags
|
||||||
|
</h2>
|
||||||
|
<div class="mb-3">
|
||||||
|
<textarea id="annotNote" rows="3" placeholder="Add a private note about this draft..."
|
||||||
|
class="w-full bg-slate-800/60 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:border-blue-500 resize-y">{{ draft.annotation.note if draft.annotation else '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="flex flex-wrap gap-1.5 mb-2" id="tagContainer">
|
||||||
|
{% if draft.annotation and draft.annotation.tags %}
|
||||||
|
{% for tag in draft.annotation.tags %}
|
||||||
|
<span class="tag-chip inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-blue-500/20 text-blue-400 border border-blue-500/30">
|
||||||
|
{{ tag }}
|
||||||
|
<button onclick="removeTag('{{ tag }}')" class="hover:text-red-400 transition ml-0.5">×</button>
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input type="text" id="newTag" placeholder="Add tag..." maxlength="30"
|
||||||
|
class="flex-1 bg-slate-800/60 border border-slate-700 rounded-lg px-3 py-1.5 text-xs text-slate-200 placeholder-slate-500 focus:outline-none focus:border-blue-500"
|
||||||
|
onkeydown="if(event.key==='Enter'){event.preventDefault();addTag();}">
|
||||||
|
<button onclick="addTag()" class="px-3 py-1.5 bg-blue-600 text-white rounded-lg text-xs font-medium hover:bg-blue-500 transition">Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onclick="saveAnnotation()" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 text-slate-300 rounded-lg text-xs font-medium hover:border-blue-500 hover:text-blue-400 transition" id="saveBtn">
|
||||||
|
Save Note
|
||||||
|
</button>
|
||||||
|
<div id="saveStatus" class="text-xs text-center mt-2 text-slate-600"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right column: Sidebar -->
|
<!-- Right column: Sidebar -->
|
||||||
@@ -193,6 +235,42 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Readiness Score -->
|
||||||
|
{% if draft.readiness and draft.readiness.score > 0 %}
|
||||||
|
<div class="detail-card rounded-xl border border-slate-800 p-5">
|
||||||
|
<h2 class="text-sm font-semibold text-slate-300 mb-3 flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/></svg>
|
||||||
|
Standards Readiness
|
||||||
|
</h2>
|
||||||
|
<!-- Gauge -->
|
||||||
|
<div class="relative w-full h-6 bg-slate-800 rounded-full overflow-hidden mb-2">
|
||||||
|
<div class="h-full rounded-full transition-all duration-700
|
||||||
|
{% if draft.readiness.score >= 60 %}bg-gradient-to-r from-green-600 to-green-400
|
||||||
|
{% elif draft.readiness.score >= 35 %}bg-gradient-to-r from-amber-600 to-amber-400
|
||||||
|
{% else %}bg-gradient-to-r from-red-600 to-red-400{% endif %}"
|
||||||
|
style="width: {{ draft.readiness.score }}%"></div>
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center text-xs font-bold text-white">
|
||||||
|
{{ draft.readiness.score }}/100
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Factor breakdown -->
|
||||||
|
<div class="space-y-1.5 mt-3">
|
||||||
|
{% for key, f in draft.readiness.factors.items() %}
|
||||||
|
<div class="flex items-center justify-between text-xs">
|
||||||
|
<span class="text-slate-500">{{ f.label }}</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-slate-600 font-mono text-[10px]">{{ f.detail }}</span>
|
||||||
|
<span class="font-mono font-medium
|
||||||
|
{% if f.value >= 0.7 %}text-green-400
|
||||||
|
{% elif f.value >= 0.4 %}text-amber-400
|
||||||
|
{% else %}text-red-400{% endif %}">+{{ f.contribution }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Metadata -->
|
<!-- Metadata -->
|
||||||
<div class="detail-card rounded-xl border border-slate-800 p-5">
|
<div class="detail-card rounded-xl border border-slate-800 p-5">
|
||||||
<h2 class="text-sm font-semibold text-slate-300 mb-3 flex items-center gap-2">
|
<h2 class="text-sm font-semibold text-slate-300 mb-3 flex items-center gap-2">
|
||||||
@@ -308,3 +386,76 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_scripts %}
|
||||||
|
<script>
|
||||||
|
const draftName = {{ draft.name | tojson }};
|
||||||
|
|
||||||
|
function addTag() {
|
||||||
|
const input = document.getElementById('newTag');
|
||||||
|
const tag = input.value.trim();
|
||||||
|
if (!tag) return;
|
||||||
|
input.value = '';
|
||||||
|
fetch(`/api/drafts/${encodeURIComponent(draftName)}/annotate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({add_tag: tag}),
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) renderTags(data.annotation.tags);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTag(tag) {
|
||||||
|
fetch(`/api/drafts/${encodeURIComponent(draftName)}/annotate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({remove_tag: tag}),
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) renderTags(data.annotation.tags);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTags(tags) {
|
||||||
|
const container = document.getElementById('tagContainer');
|
||||||
|
container.innerHTML = tags.map(t =>
|
||||||
|
`<span class="tag-chip inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-blue-500/20 text-blue-400 border border-blue-500/30">
|
||||||
|
${t}
|
||||||
|
<button onclick="removeTag('${t}')" class="hover:text-red-400 transition ml-0.5">×</button>
|
||||||
|
</span>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveAnnotation() {
|
||||||
|
const note = document.getElementById('annotNote').value;
|
||||||
|
const btn = document.getElementById('saveBtn');
|
||||||
|
const status = document.getElementById('saveStatus');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Saving...';
|
||||||
|
fetch(`/api/drafts/${encodeURIComponent(draftName)}/annotate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({note: note}),
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Save Note';
|
||||||
|
if (data.success) {
|
||||||
|
status.textContent = 'Saved';
|
||||||
|
status.className = 'text-xs text-center mt-2 text-green-400';
|
||||||
|
setTimeout(() => { status.textContent = ''; status.className = 'text-xs text-center mt-2 text-slate-600'; }, 2000);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Save Note';
|
||||||
|
status.textContent = 'Error saving';
|
||||||
|
status.className = 'text-xs text-center mt-2 text-red-400';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -33,6 +33,26 @@
|
|||||||
.dim-fill-high { background: #4ade80; }
|
.dim-fill-high { background: #4ade80; }
|
||||||
.dim-fill-mid { background: #facc15; }
|
.dim-fill-mid { background: #facc15; }
|
||||||
.dim-fill-low { background: #f87171; }
|
.dim-fill-low { background: #f87171; }
|
||||||
|
.source-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
white-space: nowrap;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.source-ietf {
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
color: #60a5fa;
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
.source-w3c {
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
color: #4ade80;
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||||
|
}
|
||||||
.cat-pill {
|
.cat-pill {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 1px 8px;
|
padding: 1px 8px;
|
||||||
@@ -128,6 +148,17 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Source dropdown -->
|
||||||
|
<div class="min-w-[120px]">
|
||||||
|
<label class="block text-xs font-medium text-slate-500 mb-1.5">Source</label>
|
||||||
|
<select name="source"
|
||||||
|
class="w-full bg-slate-800/60 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500 transition appearance-none"
|
||||||
|
style="background-image: url('data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 fill=%22none%22 viewBox=%220 0 20 20%22><path stroke=%22%236b7280%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22 stroke-width=%221.5%22 d=%22M6 8l4 4 4-4%22/></svg>'); background-position: right 0.5rem center; background-repeat: no-repeat; background-size: 1.2em 1.2em; padding-right: 2rem;">
|
||||||
|
<option value="">All sources</option>
|
||||||
|
<option value="ietf" {% if current_source == 'ietf' %}selected{% endif %}>IETF</option>
|
||||||
|
<option value="w3c" {% if current_source == 'w3c' %}selected{% endif %}>W3C</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<!-- Sort -->
|
<!-- Sort -->
|
||||||
<div class="min-w-[150px]">
|
<div class="min-w-[150px]">
|
||||||
<label class="block text-xs font-medium text-slate-500 mb-1.5">Sort by</label>
|
<label class="block text-xs font-medium text-slate-500 mb-1.5">Sort by</label>
|
||||||
@@ -141,6 +172,7 @@
|
|||||||
<option value="relevance" {% if sort == 'relevance' %}selected{% endif %}>Relevance</option>
|
<option value="relevance" {% if sort == 'relevance' %}selected{% endif %}>Relevance</option>
|
||||||
<option value="momentum" {% if sort == 'momentum' %}selected{% endif %}>Momentum</option>
|
<option value="momentum" {% if sort == 'momentum' %}selected{% endif %}>Momentum</option>
|
||||||
<option value="overlap" {% if sort == 'overlap' %}selected{% endif %}>Overlap</option>
|
<option value="overlap" {% if sort == 'overlap' %}selected{% endif %}>Overlap</option>
|
||||||
|
<option value="readiness" {% if sort == 'readiness' %}selected{% endif %}>Readiness</option>
|
||||||
<option value="name" {% if sort == 'name' %}selected{% endif %}>Name</option>
|
<option value="name" {% if sort == 'name' %}selected{% endif %}>Name</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -178,10 +210,10 @@
|
|||||||
{% if categories %}
|
{% if categories %}
|
||||||
<div class="mt-4 pt-3 border-t border-slate-800/50">
|
<div class="mt-4 pt-3 border-t border-slate-800/50">
|
||||||
<div class="flex flex-wrap gap-1.5">
|
<div class="flex flex-wrap gap-1.5">
|
||||||
<a href="/drafts?q={{ search | urlencode }}&min_score={{ min_score }}&sort={{ sort }}&dir={{ sort_dir }}"
|
<a href="/drafts?q={{ search | urlencode }}&min_score={{ min_score }}&sort={{ sort }}&dir={{ sort_dir }}&source={{ current_source }}"
|
||||||
class="cat-pill {% if not current_cat %}cat-pill-active{% endif %}">All</a>
|
class="cat-pill {% if not current_cat %}cat-pill-active{% endif %}">All</a>
|
||||||
{% for cat, count in categories.items() %}
|
{% for cat, count in categories.items() %}
|
||||||
<a href="/drafts?cat={{ cat }}&q={{ search | urlencode }}&min_score={{ min_score }}&sort={{ sort }}&dir={{ sort_dir }}"
|
<a href="/drafts?cat={{ cat }}&q={{ search | urlencode }}&min_score={{ min_score }}&sort={{ sort }}&dir={{ sort_dir }}&source={{ current_source }}"
|
||||||
class="cat-pill {% if current_cat == cat %}cat-pill-active{% endif %}">
|
class="cat-pill {% if current_cat == cat %}cat-pill-active{% endif %}">
|
||||||
{{ cat }} <span class="opacity-50">{{ count }}</span>
|
{{ cat }} <span class="opacity-50">{{ count }}</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -192,7 +224,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Results count -->
|
<!-- Results count + Compare button -->
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<p class="text-sm text-slate-500">
|
<p class="text-sm text-slate-500">
|
||||||
Showing <span class="text-slate-300 font-medium">{{ result.drafts|length }}</span> of
|
Showing <span class="text-slate-300 font-medium">{{ result.drafts|length }}</span> of
|
||||||
@@ -201,10 +233,18 @@
|
|||||||
{% if current_cat %} in <span class="text-blue-400">{{ current_cat }}</span>{% endif %}
|
{% if current_cat %} in <span class="text-blue-400">{{ current_cat }}</span>{% endif %}
|
||||||
{% if min_score > 0 %} with score >= <span class="text-blue-400">{{ min_score }}</span>{% endif %}
|
{% if min_score > 0 %} with score >= <span class="text-blue-400">{{ min_score }}</span>{% endif %}
|
||||||
</p>
|
</p>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span id="compareCount" class="text-xs text-slate-600 hidden"><span id="compareNum">0</span> selected</span>
|
||||||
|
<button onclick="goCompare()" id="compareBtn"
|
||||||
|
class="px-4 py-1.5 bg-slate-800 text-slate-500 rounded-lg text-xs font-medium border border-slate-700 cursor-not-allowed transition-colors hidden"
|
||||||
|
disabled>
|
||||||
|
Compare Selected
|
||||||
|
</button>
|
||||||
{% if result.pages > 1 %}
|
{% if result.pages > 1 %}
|
||||||
<p class="text-xs text-slate-600">Page {{ result.page }} of {{ result.pages }}</p>
|
<p class="text-xs text-slate-600">Page {{ result.page }} of {{ result.pages }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Draft Table -->
|
<!-- Draft Table -->
|
||||||
<div class="bg-slate-900/60 rounded-xl border border-slate-800 overflow-hidden">
|
<div class="bg-slate-900/60 rounded-xl border border-slate-800 overflow-hidden">
|
||||||
@@ -216,7 +256,7 @@
|
|||||||
{% set is_active = sort == field %}
|
{% set is_active = sort == field %}
|
||||||
{% set next_dir = 'asc' if (is_active and sort_dir == 'desc') else 'desc' %}
|
{% set next_dir = 'asc' if (is_active and sort_dir == 'desc') else 'desc' %}
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wide {{ extra_class }} {{ 'text-blue-400' if is_active else 'text-slate-500' }}">
|
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wide {{ extra_class }} {{ 'text-blue-400' if is_active else 'text-slate-500' }}">
|
||||||
<a href="/drafts?q={{ search }}&cat={{ current_cat }}&min_score={{ min_score }}&sort={{ field }}&dir={{ next_dir }}"
|
<a href="/drafts?q={{ search }}&cat={{ current_cat }}&min_score={{ min_score }}&sort={{ field }}&dir={{ next_dir }}&source={{ current_source }}"
|
||||||
class="hover:text-blue-400 transition inline-flex items-center gap-1"
|
class="hover:text-blue-400 transition inline-flex items-center gap-1"
|
||||||
{% if title %}title="{{ title }}"{% endif %}>
|
{% if title %}title="{{ title }}"{% endif %}>
|
||||||
{{ label }}
|
{{ label }}
|
||||||
@@ -228,6 +268,9 @@
|
|||||||
</a>
|
</a>
|
||||||
</th>
|
</th>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
<th class="px-2 py-3 w-8">
|
||||||
|
<span class="text-xs text-slate-600" title="Select drafts to compare">Cmp</span>
|
||||||
|
</th>
|
||||||
{{ sort_header("score", "Score", "w-20") }}
|
{{ sort_header("score", "Score", "w-20") }}
|
||||||
{{ sort_header("name", "Draft") }}
|
{{ sort_header("name", "Draft") }}
|
||||||
{{ sort_header("date", "Date", "w-24 hidden md:table-cell") }}
|
{{ sort_header("date", "Date", "w-24 hidden md:table-cell") }}
|
||||||
@@ -236,23 +279,32 @@
|
|||||||
{{ sort_header("relevance", "Rel", "w-20 hidden lg:table-cell", "Relevance") }}
|
{{ sort_header("relevance", "Rel", "w-20 hidden lg:table-cell", "Relevance") }}
|
||||||
{{ sort_header("momentum", "Mom", "w-20 hidden xl:table-cell", "Momentum") }}
|
{{ sort_header("momentum", "Mom", "w-20 hidden xl:table-cell", "Momentum") }}
|
||||||
{{ sort_header("overlap", "Ovl", "w-20 hidden xl:table-cell", "Overlap") }}
|
{{ sort_header("overlap", "Ovl", "w-20 hidden xl:table-cell", "Overlap") }}
|
||||||
|
{{ sort_header("readiness", "Rdy", "w-20 hidden xl:table-cell", "Standards Readiness") }}
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wide hidden md:table-cell">Categories</th>
|
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wide hidden md:table-cell">Categories</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-slate-800/30">
|
<tbody class="divide-y divide-slate-800/30">
|
||||||
{% for d in result.drafts %}
|
{% for d in result.drafts %}
|
||||||
<tr class="draft-row">
|
<tr class="draft-row">
|
||||||
|
<!-- Compare checkbox -->
|
||||||
|
<td class="px-2 py-3 text-center">
|
||||||
|
<input type="checkbox" class="compare-check rounded border-slate-600 bg-slate-800 text-blue-500 focus:ring-blue-500/30 focus:ring-offset-0 w-3.5 h-3.5 cursor-pointer"
|
||||||
|
data-name="{{ d.name }}" onchange="updateCompare()">
|
||||||
|
</td>
|
||||||
<!-- Score badge -->
|
<!-- Score badge -->
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<span class="score-badge {% if d.score >= 3.5 %}score-high{% elif d.score >= 2.5 %}score-mid{% else %}score-low{% endif %}">
|
<span class="score-badge {% if d.score >= 3.5 %}score-high{% elif d.score >= 2.5 %}score-mid{% else %}score-low{% endif %}">
|
||||||
{{ d.score }}
|
{{ d.score }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<!-- Draft name + title -->
|
<!-- Draft name + title + source badge -->
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
<a href="/drafts/{{ d.name }}" class="text-blue-400 hover:text-blue-300 font-medium text-sm transition">
|
<a href="/drafts/{{ d.name }}" class="text-blue-400 hover:text-blue-300 font-medium text-sm transition">
|
||||||
{{ d.title }}
|
{{ d.title }}
|
||||||
</a>
|
</a>
|
||||||
|
<span class="source-badge source-{{ d.source|default('ietf') }}">{{ (d.source|default('ietf'))|upper }}</span>
|
||||||
|
</div>
|
||||||
<div class="text-xs text-slate-600 mt-0.5 font-mono">{{ d.name }}</div>
|
<div class="text-xs text-slate-600 mt-0.5 font-mono">{{ d.name }}</div>
|
||||||
{% if d.summary %}
|
{% if d.summary %}
|
||||||
<div class="text-xs text-slate-500 mt-1 line-clamp-1 max-w-lg">{{ d.summary }}</div>
|
<div class="text-xs text-slate-500 mt-1 line-clamp-1 max-w-lg">{{ d.summary }}</div>
|
||||||
@@ -293,6 +345,16 @@
|
|||||||
<span class="text-xs text-slate-500 font-mono w-4 text-right">{{ d.overlap }}</span>
|
<span class="text-xs text-slate-500 font-mono w-4 text-right">{{ d.overlap }}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<!-- Readiness -->
|
||||||
|
<td class="px-4 py-3 hidden xl:table-cell">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="dim-bar-bg" style="width: 50px;">
|
||||||
|
<span class="dim-bar-fill {% if d.readiness >= 50 %}dim-fill-high{% elif d.readiness >= 25 %}dim-fill-mid{% else %}dim-fill-low{% endif %}"
|
||||||
|
style="width: {{ (d.readiness)|int }}%"></span>
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-slate-500 font-mono w-6 text-right">{{ d.readiness|int }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<!-- Categories -->
|
<!-- Categories -->
|
||||||
<td class="px-4 py-3 hidden md:table-cell">
|
<td class="px-4 py-3 hidden md:table-cell">
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
@@ -308,7 +370,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if not result.drafts %}
|
{% if not result.drafts %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="9" class="px-4 py-12 text-center text-slate-500">
|
<td colspan="11" class="px-4 py-12 text-center text-slate-500">
|
||||||
<svg class="w-12 h-12 mx-auto mb-3 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-12 h-12 mx-auto mb-3 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -326,7 +388,7 @@
|
|||||||
{% if result.pages > 1 %}
|
{% if result.pages > 1 %}
|
||||||
<nav class="flex items-center justify-center gap-1.5 mt-6">
|
<nav class="flex items-center justify-center gap-1.5 mt-6">
|
||||||
{% if result.page > 1 %}
|
{% if result.page > 1 %}
|
||||||
<a href="/drafts?page={{ result.page - 1 }}&q={{ search | urlencode }}&cat={{ current_cat | urlencode }}&min_score={{ min_score }}&sort={{ sort }}&dir={{ sort_dir }}"
|
<a href="/drafts?page={{ result.page - 1 }}&q={{ search | urlencode }}&cat={{ current_cat | urlencode }}&min_score={{ min_score }}&sort={{ sort }}&dir={{ sort_dir }}&source={{ current_source }}"
|
||||||
class="page-btn page-btn-inactive">
|
class="page-btn page-btn-inactive">
|
||||||
<svg class="w-4 h-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
|
<svg class="w-4 h-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
|
||||||
Prev
|
Prev
|
||||||
@@ -337,7 +399,7 @@
|
|||||||
{% set end_page = [result.pages, result.page + 2]|min %}
|
{% set end_page = [result.pages, result.page + 2]|min %}
|
||||||
|
|
||||||
{% if start_page > 1 %}
|
{% if start_page > 1 %}
|
||||||
<a href="/drafts?page=1&q={{ search | urlencode }}&cat={{ current_cat | urlencode }}&min_score={{ min_score }}&sort={{ sort }}&dir={{ sort_dir }}"
|
<a href="/drafts?page=1&q={{ search | urlencode }}&cat={{ current_cat | urlencode }}&min_score={{ min_score }}&sort={{ sort }}&dir={{ sort_dir }}&source={{ current_source }}"
|
||||||
class="page-btn page-btn-inactive">1</a>
|
class="page-btn page-btn-inactive">1</a>
|
||||||
{% if start_page > 2 %}<span class="text-slate-600 px-1">...</span>{% endif %}
|
{% if start_page > 2 %}<span class="text-slate-600 px-1">...</span>{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -346,19 +408,19 @@
|
|||||||
{% if p == result.page %}
|
{% if p == result.page %}
|
||||||
<span class="page-btn page-btn-active">{{ p }}</span>
|
<span class="page-btn page-btn-active">{{ p }}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="/drafts?page={{ p }}&q={{ search | urlencode }}&cat={{ current_cat | urlencode }}&min_score={{ min_score }}&sort={{ sort }}&dir={{ sort_dir }}"
|
<a href="/drafts?page={{ p }}&q={{ search | urlencode }}&cat={{ current_cat | urlencode }}&min_score={{ min_score }}&sort={{ sort }}&dir={{ sort_dir }}&source={{ current_source }}"
|
||||||
class="page-btn page-btn-inactive">{{ p }}</a>
|
class="page-btn page-btn-inactive">{{ p }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% if end_page < result.pages %}
|
{% if end_page < result.pages %}
|
||||||
{% if end_page < result.pages - 1 %}<span class="text-slate-600 px-1">...</span>{% endif %}
|
{% if end_page < result.pages - 1 %}<span class="text-slate-600 px-1">...</span>{% endif %}
|
||||||
<a href="/drafts?page={{ result.pages }}&q={{ search | urlencode }}&cat={{ current_cat | urlencode }}&min_score={{ min_score }}&sort={{ sort }}&dir={{ sort_dir }}"
|
<a href="/drafts?page={{ result.pages }}&q={{ search | urlencode }}&cat={{ current_cat | urlencode }}&min_score={{ min_score }}&sort={{ sort }}&dir={{ sort_dir }}&source={{ current_source }}"
|
||||||
class="page-btn page-btn-inactive">{{ result.pages }}</a>
|
class="page-btn page-btn-inactive">{{ result.pages }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if result.page < result.pages %}
|
{% if result.page < result.pages %}
|
||||||
<a href="/drafts?page={{ result.page + 1 }}&q={{ search | urlencode }}&cat={{ current_cat | urlencode }}&min_score={{ min_score }}&sort={{ sort }}&dir={{ sort_dir }}"
|
<a href="/drafts?page={{ result.page + 1 }}&q={{ search | urlencode }}&cat={{ current_cat | urlencode }}&min_score={{ min_score }}&sort={{ sort }}&dir={{ sort_dir }}&source={{ current_source }}"
|
||||||
class="page-btn page-btn-inactive">
|
class="page-btn page-btn-inactive">
|
||||||
Next
|
Next
|
||||||
<svg class="w-4 h-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
<svg class="w-4 h-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||||
@@ -366,4 +428,46 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_scripts %}
|
||||||
|
<script>
|
||||||
|
function updateCompare() {
|
||||||
|
const checks = document.querySelectorAll('.compare-check:checked');
|
||||||
|
const btn = document.getElementById('compareBtn');
|
||||||
|
const count = document.getElementById('compareCount');
|
||||||
|
const num = document.getElementById('compareNum');
|
||||||
|
const n = checks.length;
|
||||||
|
num.textContent = n;
|
||||||
|
|
||||||
|
if (n >= 2) {
|
||||||
|
btn.classList.remove('hidden', 'bg-slate-800', 'text-slate-500', 'cursor-not-allowed');
|
||||||
|
btn.classList.add('bg-blue-600', 'text-white', 'hover:bg-blue-500', 'cursor-pointer');
|
||||||
|
btn.disabled = false;
|
||||||
|
count.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
btn.classList.add('hidden');
|
||||||
|
count.classList.add('hidden');
|
||||||
|
btn.disabled = true;
|
||||||
|
}
|
||||||
|
// Show button area once at least 1 is selected
|
||||||
|
if (n >= 1) {
|
||||||
|
btn.classList.remove('hidden');
|
||||||
|
count.classList.remove('hidden');
|
||||||
|
if (n < 2) {
|
||||||
|
btn.classList.add('bg-slate-800', 'text-slate-500', 'cursor-not-allowed');
|
||||||
|
btn.classList.remove('bg-blue-600', 'text-white', 'hover:bg-blue-500', 'cursor-pointer');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goCompare() {
|
||||||
|
const checks = document.querySelectorAll('.compare-check:checked');
|
||||||
|
const names = Array.from(checks).map(c => c.dataset.name);
|
||||||
|
if (names.length >= 2) {
|
||||||
|
window.location.href = '/compare?drafts=' + encodeURIComponent(names.join(','));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -55,9 +55,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Gap cards sorted by severity -->
|
<!-- Gap cards sorted by severity (critical first) -->
|
||||||
<div class="space-y-4">
|
<div class="space-y-4" id="gapList">
|
||||||
{% for gap in gaps | sort(attribute='severity') %}
|
{% for gap in gaps %}
|
||||||
<a href="/gaps/{{ gap.id }}" class="block bg-slate-900 rounded-xl border
|
<a href="/gaps/{{ gap.id }}" class="block bg-slate-900 rounded-xl border
|
||||||
{% if gap.severity == 'critical' %}border-red-500/40 hover:border-red-500/60
|
{% if gap.severity == 'critical' %}border-red-500/40 hover:border-red-500/60
|
||||||
{% elif gap.severity == 'high' %}border-orange-500/30 hover:border-orange-500/50
|
{% elif gap.severity == 'high' %}border-orange-500/30 hover:border-orange-500/50
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<h1 class="text-2xl font-bold text-white">Idea Clusters</h1>
|
<h1 class="text-2xl font-bold text-white">Idea Clusters</h1>
|
||||||
<p class="text-slate-400 text-sm mt-1">Extracted ideas grouped by semantic similarity using embedding-based clustering</p>
|
<p class="text-slate-400 text-sm mt-1">Extracted ideas grouped by semantic similarity — enriched with WG and category data</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="emptyState" class="hidden">
|
<div id="emptyState" class="hidden">
|
||||||
@@ -21,19 +21,30 @@
|
|||||||
|
|
||||||
<div id="clusterContent" class="hidden">
|
<div id="clusterContent" class="hidden">
|
||||||
<!-- Stat cards -->
|
<!-- Stat cards -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||||
<div class="stat-card rounded-xl border border-slate-800 p-5">
|
<div class="stat-card rounded-xl border border-slate-800 p-5">
|
||||||
<p class="text-xs text-slate-500 uppercase tracking-wide">Total Ideas Embedded</p>
|
<p class="text-xs text-slate-500 uppercase tracking-wide">Total Ideas</p>
|
||||||
<p class="text-2xl font-bold text-white mt-1" id="statTotal">0</p>
|
<p class="text-2xl font-bold text-white mt-1" id="statTotal">0</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card rounded-xl border border-slate-800 p-5">
|
<div class="stat-card rounded-xl border border-slate-800 p-5">
|
||||||
<p class="text-xs text-slate-500 uppercase tracking-wide">Clusters Found</p>
|
<p class="text-xs text-slate-500 uppercase tracking-wide">Clusters</p>
|
||||||
<p class="text-2xl font-bold text-white mt-1" id="statClusters">0</p>
|
<p class="text-2xl font-bold text-white mt-1" id="statClusters">0</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card rounded-xl border border-slate-800 p-5">
|
<div class="stat-card rounded-xl border border-slate-800 p-5">
|
||||||
<p class="text-xs text-slate-500 uppercase tracking-wide">Avg Cluster Size</p>
|
<p class="text-xs text-slate-500 uppercase tracking-wide">Avg Size</p>
|
||||||
<p class="text-2xl font-bold text-white mt-1" id="statAvgSize">0</p>
|
<p class="text-2xl font-bold text-white mt-1" id="statAvgSize">0</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat-card rounded-xl border border-slate-800 p-5">
|
||||||
|
<p class="text-xs text-slate-500 uppercase tracking-wide">Cross-WG Clusters</p>
|
||||||
|
<p class="text-2xl font-bold text-amber-400 mt-1" id="statCrossWg">0</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter bar -->
|
||||||
|
<div class="flex flex-wrap gap-3 mb-6">
|
||||||
|
<button id="filterAll" onclick="filterClusters('all')" class="px-3 py-1.5 text-xs rounded-lg bg-blue-600 text-white">All</button>
|
||||||
|
<button id="filterCrossWg" onclick="filterClusters('cross_wg')" class="px-3 py-1.5 text-xs rounded-lg bg-slate-800 text-slate-400 hover:text-white">Cross-WG only</button>
|
||||||
|
<button id="filterLarge" onclick="filterClusters('large')" class="px-3 py-1.5 text-xs rounded-lg bg-slate-800 text-slate-400 hover:text-white">Large (10+)</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- t-SNE Scatter -->
|
<!-- t-SNE Scatter -->
|
||||||
@@ -46,7 +57,7 @@
|
|||||||
<!-- Treemap -->
|
<!-- Treemap -->
|
||||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5 mb-6">
|
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5 mb-6">
|
||||||
<h2 class="text-sm font-semibold text-slate-300 mb-1">Cluster Sizes</h2>
|
<h2 class="text-sm font-semibold text-slate-300 mb-1">Cluster Sizes</h2>
|
||||||
<p class="text-xs text-slate-500 mb-3">Treemap showing relative sizes of each idea cluster.</p>
|
<p class="text-xs text-slate-500 mb-3">Treemap showing relative sizes of each idea cluster. Amber borders = cross-WG clusters.</p>
|
||||||
<div id="treemapPlot" style="height: 450px;"></div>
|
<div id="treemapPlot" style="height: 450px;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -72,6 +83,9 @@ const PALETTE = [
|
|||||||
'#3b82f6', '#ef4444', '#22c55e', '#a855f7', '#f59e0b',
|
'#3b82f6', '#ef4444', '#22c55e', '#a855f7', '#f59e0b',
|
||||||
'#06b6d4', '#ec4899', '#84cc16', '#f97316', '#8b5cf6',
|
'#06b6d4', '#ec4899', '#84cc16', '#f97316', '#8b5cf6',
|
||||||
'#14b8a6', '#e11d48', '#64748b', '#eab308', '#6366f1',
|
'#14b8a6', '#e11d48', '#64748b', '#eab308', '#6366f1',
|
||||||
|
'#fb923c', '#2dd4bf', '#c084fc', '#facc15', '#4ade80',
|
||||||
|
'#f472b6', '#38bdf8', '#a3e635', '#fb7185', '#818cf8',
|
||||||
|
'#34d399', '#fbbf24', '#e879f9', '#22d3ee', '#a78bfa',
|
||||||
];
|
];
|
||||||
|
|
||||||
const data = {{ clusters | tojson }};
|
const data = {{ clusters | tojson }};
|
||||||
@@ -81,46 +95,42 @@ if (data.empty) {
|
|||||||
} else {
|
} else {
|
||||||
document.getElementById('clusterContent').classList.remove('hidden');
|
document.getElementById('clusterContent').classList.remove('hidden');
|
||||||
|
|
||||||
// Stats
|
|
||||||
const stats = data.stats;
|
const stats = data.stats;
|
||||||
|
const crossWgCount = data.clusters.filter(c => c.cross_wg).length;
|
||||||
document.getElementById('statTotal').textContent = stats.total.toLocaleString();
|
document.getElementById('statTotal').textContent = stats.total.toLocaleString();
|
||||||
document.getElementById('statClusters').textContent = stats.num_clusters.toLocaleString();
|
document.getElementById('statClusters').textContent = stats.num_clusters.toLocaleString();
|
||||||
document.getElementById('statAvgSize').textContent = stats.num_clusters > 0
|
document.getElementById('statAvgSize').textContent = stats.num_clusters > 0
|
||||||
? (stats.clustered / stats.num_clusters).toFixed(1) : '0';
|
? (stats.clustered / stats.num_clusters).toFixed(1) : '0';
|
||||||
|
document.getElementById('statCrossWg').textContent = crossWgCount;
|
||||||
|
|
||||||
// --- t-SNE Scatter ---
|
// --- t-SNE Scatter ---
|
||||||
if (data.scatter.length > 0) {
|
if (data.scatter.length > 0) {
|
||||||
// Group by cluster_id
|
|
||||||
const groups = {};
|
const groups = {};
|
||||||
data.scatter.forEach(pt => {
|
data.scatter.forEach(pt => {
|
||||||
if (!groups[pt.cluster_id]) groups[pt.cluster_id] = { x: [], y: [], text: [], names: [] };
|
if (!groups[pt.cluster_id]) groups[pt.cluster_id] = { x: [], y: [], text: [], names: [], wgs: [] };
|
||||||
groups[pt.cluster_id].x.push(pt.x);
|
groups[pt.cluster_id].x.push(pt.x);
|
||||||
groups[pt.cluster_id].y.push(pt.y);
|
groups[pt.cluster_id].y.push(pt.y);
|
||||||
groups[pt.cluster_id].text.push(pt.title);
|
groups[pt.cluster_id].text.push(pt.title);
|
||||||
groups[pt.cluster_id].names.push(pt.draft_name);
|
groups[pt.cluster_id].names.push(pt.draft_name);
|
||||||
});
|
groups[pt.cluster_id].wgs.push(pt.wg || 'none');
|
||||||
|
|
||||||
// Map cluster_id to cluster theme
|
|
||||||
const clusterThemes = {};
|
|
||||||
data.clusters.forEach((c, i) => {
|
|
||||||
// Find the original cluster_id by matching scatter points
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const clusterIds = Object.keys(groups).sort((a, b) => (groups[b].x.length - groups[a].x.length));
|
const clusterIds = Object.keys(groups).sort((a, b) => (groups[b].x.length - groups[a].x.length));
|
||||||
const traces = clusterIds.map((cid, i) => {
|
const traces = clusterIds.map((cid, i) => {
|
||||||
const g = groups[cid];
|
const g = groups[cid];
|
||||||
const theme = data.clusters[i] ? data.clusters[i].theme : `Cluster ${cid}`;
|
const theme = data.clusters[i] ? data.clusters[i].theme : `Cluster ${cid}`;
|
||||||
|
const hoverTexts = g.text.map((t, j) => `${t}<br><span style="color:#64748b">${g.wgs[j]}</span>`);
|
||||||
return {
|
return {
|
||||||
x: g.x, y: g.y, text: g.text, name: theme,
|
x: g.x, y: g.y, text: hoverTexts, name: theme,
|
||||||
customdata: g.names,
|
customdata: g.names,
|
||||||
mode: 'markers', type: 'scatter',
|
mode: 'markers', type: 'scatter',
|
||||||
marker: {
|
marker: {
|
||||||
size: 6,
|
size: 7,
|
||||||
color: PALETTE[i % PALETTE.length],
|
color: PALETTE[i % PALETTE.length],
|
||||||
opacity: 0.8,
|
opacity: 0.8,
|
||||||
line: { width: 0.5, color: 'rgba(255,255,255,0.15)' },
|
line: { width: 0.5, color: 'rgba(255,255,255,0.15)' },
|
||||||
},
|
},
|
||||||
hovertemplate: '<b>%{text}</b><extra>%{customdata}</extra>',
|
hovertemplate: '%{text}<extra>%{customdata}</extra>',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -135,26 +145,33 @@ if (data.empty) {
|
|||||||
|
|
||||||
document.getElementById('scatterPlot').on('plotly_click', function(ev) {
|
document.getElementById('scatterPlot').on('plotly_click', function(ev) {
|
||||||
const pt = ev.points[0];
|
const pt = ev.points[0];
|
||||||
if (pt.customdata) {
|
if (pt.customdata) window.location.href = '/drafts/' + pt.customdata;
|
||||||
window.location.href = '/drafts/' + pt.customdata;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Treemap ---
|
// --- Treemap ---
|
||||||
if (data.clusters.length > 0) {
|
if (data.clusters.length > 0) {
|
||||||
const labels = data.clusters.map(c => c.theme);
|
const labels = data.clusters.map(c => c.cross_wg ? `${c.theme} ⚡` : c.theme);
|
||||||
const values = data.clusters.map(c => c.size);
|
const values = data.clusters.map(c => c.size);
|
||||||
const colors = data.clusters.map((_, i) => PALETTE[i % PALETTE.length]);
|
const colors = data.clusters.map((c, i) => c.cross_wg
|
||||||
|
? PALETTE[i % PALETTE.length] : PALETTE[i % PALETTE.length]);
|
||||||
|
const hoverTexts = data.clusters.map(c => {
|
||||||
|
const wgs = (c.wgs || []).filter(w => w.wg !== 'none').map(w => `${w.wg}(${w.count})`).join(', ');
|
||||||
|
const cats = (c.categories || []).map(cat => cat.cat).join(', ');
|
||||||
|
return `<b>${c.theme}</b><br>${c.size} ideas, ${c.drafts.length} drafts` +
|
||||||
|
(wgs ? `<br>WGs: ${wgs}` : '') +
|
||||||
|
(cats ? `<br>Categories: ${cats}` : '');
|
||||||
|
});
|
||||||
|
|
||||||
Plotly.newPlot('treemapPlot', [{
|
Plotly.newPlot('treemapPlot', [{
|
||||||
type: 'treemap',
|
type: 'treemap',
|
||||||
labels: labels,
|
labels: labels,
|
||||||
parents: labels.map(() => ''),
|
parents: labels.map(() => ''),
|
||||||
values: values,
|
values: values,
|
||||||
|
text: hoverTexts,
|
||||||
textinfo: 'label+value',
|
textinfo: 'label+value',
|
||||||
marker: { colors: colors },
|
marker: { colors: colors },
|
||||||
hovertemplate: '<b>%{label}</b><br>%{value} ideas<extra></extra>',
|
hovertemplate: '%{text}<extra></extra>',
|
||||||
}], {
|
}], {
|
||||||
...PLOTLY_LAYOUT,
|
...PLOTLY_LAYOUT,
|
||||||
margin: { t: 10, r: 10, b: 10, l: 10 },
|
margin: { t: 10, r: 10, b: 10, l: 10 },
|
||||||
@@ -163,31 +180,60 @@ if (data.empty) {
|
|||||||
|
|
||||||
// --- Cluster Cards ---
|
// --- Cluster Cards ---
|
||||||
const grid = document.getElementById('clusterGrid');
|
const grid = document.getElementById('clusterGrid');
|
||||||
|
|
||||||
|
function renderCards(filter) {
|
||||||
|
grid.innerHTML = '';
|
||||||
data.clusters.forEach((cluster, i) => {
|
data.clusters.forEach((cluster, i) => {
|
||||||
|
if (filter === 'cross_wg' && !cluster.cross_wg) return;
|
||||||
|
if (filter === 'large' && cluster.size < 10) return;
|
||||||
|
|
||||||
const color = PALETTE[i % PALETTE.length];
|
const color = PALETTE[i % PALETTE.length];
|
||||||
const topIdeas = cluster.ideas.slice(0, 3);
|
const topIdeas = cluster.ideas.slice(0, 5);
|
||||||
const ideaListHtml = topIdeas.map(idea =>
|
const ideaListHtml = topIdeas.map(idea =>
|
||||||
`<li class="text-xs text-slate-400 truncate" title="${idea.title}">${idea.title}</li>`
|
`<li class="text-xs text-slate-400 truncate" title="${idea.description || idea.title}">
|
||||||
|
<span class="text-slate-300">${idea.title}</span>
|
||||||
|
</li>`
|
||||||
).join('');
|
).join('');
|
||||||
const extraCount = cluster.size - topIdeas.length;
|
const extraCount = cluster.size - topIdeas.length;
|
||||||
const extraHtml = extraCount > 0
|
const extraHtml = extraCount > 0
|
||||||
? `<li class="text-xs text-slate-600">+${extraCount} more</li>` : '';
|
? `<li class="text-xs text-slate-600">+${extraCount} more</li>` : '';
|
||||||
|
|
||||||
|
// WG badges
|
||||||
|
const wgBadges = (cluster.wgs || []).filter(w => w.wg !== 'none').map(w =>
|
||||||
|
`<span class="inline-block bg-amber-900/30 text-amber-400 text-xs px-2 py-0.5 rounded border border-amber-800/30">${w.wg} (${w.count})</span>`
|
||||||
|
).join(' ');
|
||||||
|
const noneCount = (cluster.wgs || []).find(w => w.wg === 'none');
|
||||||
|
const noneHtml = noneCount
|
||||||
|
? `<span class="text-xs text-slate-600">${noneCount.count} individual</span>` : '';
|
||||||
|
|
||||||
|
// Category badges
|
||||||
|
const catBadges = (cluster.categories || []).map(c =>
|
||||||
|
`<span class="inline-block bg-slate-800 text-slate-400 text-xs px-2 py-0.5 rounded">${c.cat}</span>`
|
||||||
|
).join(' ');
|
||||||
|
|
||||||
|
// Draft badges
|
||||||
const draftBadges = cluster.drafts.slice(0, 4).map(d =>
|
const draftBadges = cluster.drafts.slice(0, 4).map(d =>
|
||||||
`<a href="/drafts/${d}" class="inline-block bg-slate-800 text-slate-400 text-xs px-2 py-0.5 rounded hover:text-blue-400 truncate max-w-[140px]" title="${d}">${d.replace('draft-', '').substring(0, 20)}</a>`
|
`<a href="/drafts/${d}" class="inline-block bg-slate-800 text-slate-400 text-xs px-2 py-0.5 rounded hover:text-blue-400 truncate max-w-[160px]" title="${d}">${d.replace('draft-', '').substring(0, 22)}</a>`
|
||||||
).join(' ');
|
).join(' ');
|
||||||
const extraDrafts = cluster.drafts.length > 4
|
const extraDrafts = cluster.drafts.length > 4
|
||||||
? `<span class="text-xs text-slate-600">+${cluster.drafts.length - 4}</span>` : '';
|
? `<span class="text-xs text-slate-600">+${cluster.drafts.length - 4}</span>` : '';
|
||||||
|
|
||||||
|
const crossBadge = cluster.cross_wg
|
||||||
|
? `<span class="text-xs bg-amber-900/30 text-amber-400 px-1.5 py-0.5 rounded">cross-WG</span>` : '';
|
||||||
|
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'bg-slate-900 rounded-xl border border-slate-800 p-5';
|
card.className = 'bg-slate-900 rounded-xl border p-5 ' +
|
||||||
|
(cluster.cross_wg ? 'border-amber-800/40' : 'border-slate-800');
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<div class="flex items-center gap-2 mb-3">
|
||||||
<div class="w-3 h-3 rounded-full" style="background: ${color}"></div>
|
<div class="w-3 h-3 rounded-full flex-shrink-0" style="background: ${color}"></div>
|
||||||
<h3 class="text-sm font-semibold text-white">${cluster.theme}</h3>
|
<h3 class="text-sm font-semibold text-white truncate">${cluster.theme}</h3>
|
||||||
<span class="ml-auto text-xs text-slate-500">${cluster.size} ideas</span>
|
${crossBadge}
|
||||||
|
<span class="ml-auto text-xs text-slate-500 flex-shrink-0">${cluster.size} ideas</span>
|
||||||
</div>
|
</div>
|
||||||
<ul class="space-y-1 mb-3">${ideaListHtml}${extraHtml}</ul>
|
<ul class="space-y-1 mb-3">${ideaListHtml}${extraHtml}</ul>
|
||||||
|
${(wgBadges || noneHtml) ? `<div class="mb-2"><p class="text-xs text-slate-500 mb-1">Working Groups</p><div class="flex flex-wrap gap-1">${wgBadges} ${noneHtml}</div></div>` : ''}
|
||||||
|
${catBadges ? `<div class="mb-2"><p class="text-xs text-slate-500 mb-1">Categories</p><div class="flex flex-wrap gap-1">${catBadges}</div></div>` : ''}
|
||||||
<div class="border-t border-slate-800 pt-3">
|
<div class="border-t border-slate-800 pt-3">
|
||||||
<p class="text-xs text-slate-500 mb-1">${cluster.drafts.length} source draft${cluster.drafts.length !== 1 ? 's' : ''}</p>
|
<p class="text-xs text-slate-500 mb-1">${cluster.drafts.length} source draft${cluster.drafts.length !== 1 ? 's' : ''}</p>
|
||||||
<div class="flex flex-wrap gap-1">${draftBadges}${extraDrafts}</div>
|
<div class="flex flex-wrap gap-1">${draftBadges}${extraDrafts}</div>
|
||||||
@@ -196,5 +242,28 @@ if (data.empty) {
|
|||||||
grid.appendChild(card);
|
grid.appendChild(card);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderCards('all');
|
||||||
|
|
||||||
|
// Filter buttons
|
||||||
|
window.filterClusters = function(filter) {
|
||||||
|
document.querySelectorAll('[id^="filter"]').forEach(b => {
|
||||||
|
b.className = b.id === 'filter' + filter.charAt(0).toUpperCase() + filter.slice(1).replace('_w', 'W').replace('_', '')
|
||||||
|
? 'px-3 py-1.5 text-xs rounded-lg bg-blue-600 text-white'
|
||||||
|
: 'px-3 py-1.5 text-xs rounded-lg bg-slate-800 text-slate-400 hover:text-white';
|
||||||
|
});
|
||||||
|
// Simpler: just match by id
|
||||||
|
['filterAll', 'filterCrossWg', 'filterLarge'].forEach(id => {
|
||||||
|
const btn = document.getElementById(id);
|
||||||
|
const isActive = (filter === 'all' && id === 'filterAll') ||
|
||||||
|
(filter === 'cross_wg' && id === 'filterCrossWg') ||
|
||||||
|
(filter === 'large' && id === 'filterLarge');
|
||||||
|
btn.className = isActive
|
||||||
|
? 'px-3 py-1.5 text-xs rounded-lg bg-blue-600 text-white'
|
||||||
|
: 'px-3 py-1.5 text-xs rounded-lg bg-slate-800 text-slate-400 hover:text-white';
|
||||||
|
});
|
||||||
|
renderCards(filter);
|
||||||
|
};
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -64,6 +64,14 @@
|
|||||||
{% if idea.type %}
|
{% if idea.type %}
|
||||||
<span class="px-2 py-0.5 rounded text-[10px] font-medium bg-blue-500/20 text-blue-400 whitespace-nowrap">{{ idea.type }}</span>
|
<span class="px-2 py-0.5 rounded text-[10px] font-medium bg-blue-500/20 text-blue-400 whitespace-nowrap">{{ idea.type }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if idea.novelty_score is not none and idea.novelty_score %}
|
||||||
|
<span class="px-1.5 py-0.5 rounded text-[10px] font-mono
|
||||||
|
{% if idea.novelty_score >= 4 %}bg-green-500/20 text-green-400
|
||||||
|
{% elif idea.novelty_score >= 3 %}bg-amber-500/20 text-amber-400
|
||||||
|
{% elif idea.novelty_score >= 2 %}bg-orange-500/20 text-orange-400
|
||||||
|
{% else %}bg-red-500/20 text-red-400{% endif %}"
|
||||||
|
title="Novelty score (1-5)">N:{{ idea.novelty_score }}</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-slate-500 leading-relaxed">{{ idea.description }}</p>
|
<p class="text-xs text-slate-500 leading-relaxed">{{ idea.description }}</p>
|
||||||
<a href="/drafts/{{ idea.draft_name }}" class="text-[10px] text-slate-600 hover:text-blue-400 transition mt-1 inline-block font-mono">{{ idea.draft_name }}</a>
|
<a href="/drafts/{{ idea.draft_name }}" class="text-[10px] text-slate-600 hover:text-blue-400 transition mt-1 inline-block font-mono">{{ idea.draft_name }}</a>
|
||||||
|
|||||||
@@ -111,6 +111,71 @@ html += `
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Pipeline progress
|
||||||
|
const pl = data.pipeline || {};
|
||||||
|
const cost = data.cost || {};
|
||||||
|
if (pl.total_drafts) {
|
||||||
|
const pctRated = Math.round((pl.rated / pl.total_drafts) * 100);
|
||||||
|
const pctEmbedded = Math.round((pl.embedded / pl.total_drafts) * 100);
|
||||||
|
const pctIdeas = Math.round((pl.with_ideas / pl.total_drafts) * 100);
|
||||||
|
|
||||||
|
function progressBar(pct, color) {
|
||||||
|
return `<div class="w-full bg-slate-800 rounded-full h-2.5 mt-1.5">
|
||||||
|
<div class="h-2.5 rounded-full ${color}" style="width: ${pct}%"></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<h2 class="text-lg font-semibold text-white mb-3">Pipeline Progress</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||||
|
<div class="stat-card rounded-xl border border-slate-800 p-4">
|
||||||
|
<div class="flex justify-between items-baseline">
|
||||||
|
<span class="text-xs text-slate-400">Rated</span>
|
||||||
|
<span class="text-sm font-bold text-blue-400">${pl.rated} / ${pl.total_drafts}</span>
|
||||||
|
</div>
|
||||||
|
${progressBar(pctRated, 'bg-blue-500')}
|
||||||
|
<div class="text-xs text-slate-600 mt-1 text-right">${pctRated}%</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card rounded-xl border border-slate-800 p-4">
|
||||||
|
<div class="flex justify-between items-baseline">
|
||||||
|
<span class="text-xs text-slate-400">Embedded</span>
|
||||||
|
<span class="text-sm font-bold text-purple-400">${pl.embedded} / ${pl.total_drafts}</span>
|
||||||
|
</div>
|
||||||
|
${progressBar(pctEmbedded, 'bg-purple-500')}
|
||||||
|
<div class="text-xs text-slate-600 mt-1 text-right">${pctEmbedded}%</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card rounded-xl border border-slate-800 p-4">
|
||||||
|
<div class="flex justify-between items-baseline">
|
||||||
|
<span class="text-xs text-slate-400">Ideas Extracted</span>
|
||||||
|
<span class="text-sm font-bold text-green-400">${pl.with_ideas} / ${pl.total_drafts}</span>
|
||||||
|
</div>
|
||||||
|
${progressBar(pctIdeas, 'bg-green-500')}
|
||||||
|
<div class="text-xs text-slate-600 mt-1 text-right">${pctIdeas}%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||||
|
<div class="stat-card rounded-xl border border-slate-800 p-4">
|
||||||
|
<div class="text-2xl font-bold text-slate-200">${pl.total_drafts}</div>
|
||||||
|
<div class="text-xs text-slate-400 mt-1">Total Documents</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card rounded-xl border border-slate-800 p-4">
|
||||||
|
<div class="text-2xl font-bold text-slate-200">${pl.idea_total}</div>
|
||||||
|
<div class="text-xs text-slate-400 mt-1">Total Ideas</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card rounded-xl border border-slate-800 p-4">
|
||||||
|
<div class="text-2xl font-bold text-slate-200">${pl.gap_count}</div>
|
||||||
|
<div class="text-xs text-slate-400 mt-1">Gaps Identified</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card rounded-xl border border-slate-800 p-4">
|
||||||
|
<div class="text-xl font-bold text-amber-400">$${cost.estimated_usd || '0.00'}</div>
|
||||||
|
<div class="text-xs text-slate-400 mt-1">Est. API Cost</div>
|
||||||
|
<div class="text-xs text-slate-600 mt-0.5">${(cost.input_tokens || 0).toLocaleString()} in / ${(cost.output_tokens || 0).toLocaleString()} out</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
// New drafts over time chart
|
// New drafts over time chart
|
||||||
const runs = data.runs.slice().reverse(); // chronological order
|
const runs = data.runs.slice().reverse(); // chronological order
|
||||||
if (runs.length > 1) {
|
if (runs.length > 1) {
|
||||||
|
|||||||
149
src/webui/templates/search_results.html
Normal file
149
src/webui/templates/search_results.html
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% set active_page = "search" %}
|
||||||
|
|
||||||
|
{% block title %}Search: {{ query }} — IETF Draft Analyzer{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-white">Search Results</h1>
|
||||||
|
{% if query %}
|
||||||
|
<p class="text-slate-400 text-sm mt-1">
|
||||||
|
Found <span class="text-slate-300 font-medium">{{ total }}</span> results for
|
||||||
|
"<span class="text-blue-400">{{ query }}</span>"
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-slate-400 text-sm mt-1">Enter a search query to find drafts, ideas, authors, and gaps.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search form -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<form action="/search" method="get" class="flex gap-3 max-w-xl">
|
||||||
|
<input type="text" name="q" value="{{ query }}" placeholder="Search drafts, ideas, authors, gaps..."
|
||||||
|
autofocus
|
||||||
|
class="flex-1 bg-slate-800/60 border border-slate-700 rounded-lg px-4 py-2.5 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition">
|
||||||
|
<button type="submit" class="px-5 py-2.5 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-500 transition-colors">
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if query %}
|
||||||
|
|
||||||
|
<!-- Drafts -->
|
||||||
|
{% if results.drafts %}
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-lg font-semibold text-white mb-3 flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||||
|
Drafts <span class="text-sm font-normal text-slate-500">({{ results.drafts|length }})</span>
|
||||||
|
</h2>
|
||||||
|
<div class="bg-slate-900/60 rounded-xl border border-slate-800 divide-y divide-slate-800/30">
|
||||||
|
{% for d in results.drafts %}
|
||||||
|
<div class="px-5 py-3 hover:bg-slate-800/30 transition">
|
||||||
|
<a href="/drafts/{{ d.name }}" class="text-blue-400 hover:text-blue-300 font-medium text-sm transition">
|
||||||
|
{{ d.title }}
|
||||||
|
</a>
|
||||||
|
<div class="text-xs text-slate-600 mt-0.5 font-mono">{{ d.name }}</div>
|
||||||
|
{% if d.abstract %}
|
||||||
|
<p class="text-xs text-slate-500 mt-1 line-clamp-2">{{ d.abstract }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="flex gap-3 mt-1 text-xs text-slate-600">
|
||||||
|
{% if d.date %}<span>{{ d.date[:10] }}</span>{% endif %}
|
||||||
|
<span>{{ d.group }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Ideas -->
|
||||||
|
{% if results.ideas %}
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-lg font-semibold text-white mb-3 flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
|
||||||
|
Ideas <span class="text-sm font-normal text-slate-500">({{ results.ideas|length }})</span>
|
||||||
|
</h2>
|
||||||
|
<div class="bg-slate-900/60 rounded-xl border border-slate-800 divide-y divide-slate-800/30">
|
||||||
|
{% for idea in results.ideas %}
|
||||||
|
<div class="px-5 py-3 hover:bg-slate-800/30 transition">
|
||||||
|
<div class="text-sm text-slate-200 font-medium">{{ idea.title }}</div>
|
||||||
|
{% if idea.description %}
|
||||||
|
<p class="text-xs text-slate-500 mt-1 line-clamp-2">{{ idea.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="flex gap-3 mt-1 text-xs text-slate-600">
|
||||||
|
{% if idea.type %}<span class="text-slate-500">{{ idea.type }}</span>{% endif %}
|
||||||
|
<a href="/drafts/{{ idea.draft_name }}" class="text-blue-500 hover:text-blue-400">{{ idea.draft_name }}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Authors -->
|
||||||
|
{% if results.authors %}
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-lg font-semibold text-white mb-3 flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||||
|
Authors <span class="text-sm font-normal text-slate-500">({{ results.authors|length }})</span>
|
||||||
|
</h2>
|
||||||
|
<div class="bg-slate-900/60 rounded-xl border border-slate-800">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-0 divide-y md:divide-y-0 divide-slate-800/30">
|
||||||
|
{% for author in results.authors %}
|
||||||
|
<div class="px-5 py-3 hover:bg-slate-800/30 transition {% if not loop.last %}border-b md:border-b-0 md:border-r border-slate-800/30{% endif %}">
|
||||||
|
<div class="text-sm text-slate-200 font-medium">{{ author.name }}</div>
|
||||||
|
{% if author.affiliation %}
|
||||||
|
<div class="text-xs text-slate-500 mt-0.5">{{ author.affiliation }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Gaps -->
|
||||||
|
{% if results.gaps %}
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-lg font-semibold text-white mb-3 flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>
|
||||||
|
Gaps <span class="text-sm font-normal text-slate-500">({{ results.gaps|length }})</span>
|
||||||
|
</h2>
|
||||||
|
<div class="bg-slate-900/60 rounded-xl border border-slate-800 divide-y divide-slate-800/30">
|
||||||
|
{% for gap in results.gaps %}
|
||||||
|
<div class="px-5 py-3 hover:bg-slate-800/30 transition">
|
||||||
|
<a href="/gaps/{{ gap.id }}" class="text-blue-400 hover:text-blue-300 font-medium text-sm transition">
|
||||||
|
{{ gap.topic }}
|
||||||
|
</a>
|
||||||
|
{% if gap.description %}
|
||||||
|
<p class="text-xs text-slate-500 mt-1 line-clamp-2">{{ gap.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="flex gap-3 mt-1 text-xs">
|
||||||
|
{% if gap.category %}<span class="text-slate-500">{{ gap.category }}</span>{% endif %}
|
||||||
|
{% if gap.severity %}
|
||||||
|
<span class="{% if gap.severity == 'high' %}text-red-400{% elif gap.severity == 'medium' %}text-yellow-400{% else %}text-green-400{% endif %}">
|
||||||
|
{{ gap.severity }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- No results -->
|
||||||
|
{% if total == 0 %}
|
||||||
|
<div class="text-center py-16">
|
||||||
|
<svg class="w-16 h-16 mx-auto mb-4 text-slate-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-slate-500 text-sm">No results found for "<span class="text-slate-400">{{ query }}</span>"</p>
|
||||||
|
<p class="text-slate-600 text-xs mt-2">Try different keywords or check the spelling.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
168
tests/conftest.py
Normal file
168
tests/conftest.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"""Shared fixtures for IETF Draft Analyzer tests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ietf_analyzer.config import Config
|
||||||
|
from ietf_analyzer.db import Database, SCHEMA
|
||||||
|
from ietf_analyzer.models import Author, Draft, Rating
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tmp_db(tmp_path):
|
||||||
|
"""Create an in-memory Database with all tables initialized."""
|
||||||
|
cfg = Config(
|
||||||
|
data_dir=str(tmp_path),
|
||||||
|
db_path=str(tmp_path / "test.db"),
|
||||||
|
)
|
||||||
|
db = Database(cfg)
|
||||||
|
# Force connection + schema creation
|
||||||
|
_ = db.conn
|
||||||
|
yield db
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_draft():
|
||||||
|
"""Return a Draft object with realistic data."""
|
||||||
|
return Draft(
|
||||||
|
name="draft-test-ai-agent-protocol",
|
||||||
|
rev="02",
|
||||||
|
title="AI Agent Communication Protocol",
|
||||||
|
abstract="This document defines a protocol for autonomous AI agents to communicate with each other in a standardized manner.",
|
||||||
|
time="2025-06-15T12:00:00+00:00",
|
||||||
|
dt_id=12345,
|
||||||
|
pages=28,
|
||||||
|
words=12000,
|
||||||
|
group="dispatch",
|
||||||
|
group_uri="/api/v1/group/group/1234/",
|
||||||
|
expires="2025-12-15T12:00:00+00:00",
|
||||||
|
ad=None,
|
||||||
|
shepherd=None,
|
||||||
|
states=["I-D Exists"],
|
||||||
|
full_text="Internet-Draft: AI Agent Communication Protocol\n\nAbstract\n\nThis document defines...",
|
||||||
|
categories=["A2A protocols", "Agent discovery/reg"],
|
||||||
|
tags=["ai", "agent"],
|
||||||
|
fetched_at="2025-06-20T10:00:00+00:00",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_rating():
|
||||||
|
"""Return a Rating object with realistic data."""
|
||||||
|
return Rating(
|
||||||
|
draft_name="draft-test-ai-agent-protocol",
|
||||||
|
novelty=4,
|
||||||
|
maturity=3,
|
||||||
|
overlap=2,
|
||||||
|
momentum=3,
|
||||||
|
relevance=5,
|
||||||
|
summary="Defines a novel protocol for AI agent communication with discovery and auth mechanisms.",
|
||||||
|
novelty_note="Unique approach to agent handshake",
|
||||||
|
maturity_note="Early stage but well-structured",
|
||||||
|
overlap_note="Partially overlaps with MCP drafts",
|
||||||
|
momentum_note="Active working group interest",
|
||||||
|
relevance_note="Directly addresses core AI agent interop",
|
||||||
|
categories=["A2A protocols", "Agent discovery/reg"],
|
||||||
|
rated_at="2025-06-20T10:00:00+00:00",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_draft(name, title, time, group=None, pages=10, categories=None):
|
||||||
|
"""Helper to create Draft objects for seeding."""
|
||||||
|
return Draft(
|
||||||
|
name=name,
|
||||||
|
rev="00",
|
||||||
|
title=title,
|
||||||
|
abstract=f"Abstract for {title}.",
|
||||||
|
time=time,
|
||||||
|
dt_id=None,
|
||||||
|
pages=pages,
|
||||||
|
words=pages * 400,
|
||||||
|
group=group,
|
||||||
|
categories=categories or [],
|
||||||
|
fetched_at=datetime.now(timezone.utc).isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_rating(draft_name, novelty, maturity, overlap, momentum, relevance, categories=None):
|
||||||
|
"""Helper to create Rating objects for seeding."""
|
||||||
|
return Rating(
|
||||||
|
draft_name=draft_name,
|
||||||
|
novelty=novelty,
|
||||||
|
maturity=maturity,
|
||||||
|
overlap=overlap,
|
||||||
|
momentum=momentum,
|
||||||
|
relevance=relevance,
|
||||||
|
summary=f"Summary for {draft_name}.",
|
||||||
|
categories=categories or ["A2A protocols"],
|
||||||
|
rated_at=datetime.now(timezone.utc).isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def seeded_db(tmp_db):
|
||||||
|
"""Populate tmp_db with 5 drafts, ratings, ideas, authors, and refs."""
|
||||||
|
db = tmp_db
|
||||||
|
|
||||||
|
drafts = [
|
||||||
|
_make_draft("draft-alpha-agent-comm", "Alpha Agent Communication", "2025-01-10", "dispatch", 20, ["A2A protocols"]),
|
||||||
|
_make_draft("draft-beta-ml-traffic", "Beta ML Traffic Optimization", "2025-02-15", "netmod", 15, ["ML traffic mgmt"]),
|
||||||
|
_make_draft("draft-gamma-agent-id", "Gamma Agent Identity", "2025-03-20", "secdispatch", 12, ["Agent identity/auth"]),
|
||||||
|
_make_draft("draft-delta-safety", "Delta AI Safety Framework", "2025-04-25", None, 30, ["AI safety/alignment"]),
|
||||||
|
_make_draft("draft-epsilon-discovery", "Epsilon Agent Discovery", "2025-05-30", "dispatch", 8, ["Agent discovery/reg"]),
|
||||||
|
]
|
||||||
|
for d in drafts:
|
||||||
|
db.upsert_draft(d)
|
||||||
|
|
||||||
|
ratings = [
|
||||||
|
_make_rating("draft-alpha-agent-comm", 4, 3, 2, 3, 5, ["A2A protocols"]),
|
||||||
|
_make_rating("draft-beta-ml-traffic", 3, 4, 3, 2, 3, ["ML traffic mgmt"]),
|
||||||
|
_make_rating("draft-gamma-agent-id", 5, 2, 1, 4, 4, ["Agent identity/auth"]),
|
||||||
|
_make_rating("draft-delta-safety", 3, 3, 4, 3, 4, ["AI safety/alignment"]),
|
||||||
|
_make_rating("draft-epsilon-discovery", 4, 2, 2, 5, 5, ["Agent discovery/reg"]),
|
||||||
|
]
|
||||||
|
for r in ratings:
|
||||||
|
db.upsert_rating(r)
|
||||||
|
|
||||||
|
# Ideas
|
||||||
|
db.insert_ideas("draft-alpha-agent-comm", [
|
||||||
|
{"title": "Agent Handshake", "description": "Three-way handshake for agents", "type": "protocol"},
|
||||||
|
{"title": "Capability Negotiation", "description": "Agents advertise capabilities", "type": "mechanism"},
|
||||||
|
])
|
||||||
|
db.insert_ideas("draft-beta-ml-traffic", [
|
||||||
|
{"title": "ML Traffic Classifier", "description": "Classify traffic using ML", "type": "mechanism"},
|
||||||
|
])
|
||||||
|
db.insert_ideas("draft-gamma-agent-id", [
|
||||||
|
{"title": "Agent Certificate", "description": "X.509 extension for agents", "type": "extension"},
|
||||||
|
])
|
||||||
|
|
||||||
|
# Authors
|
||||||
|
author1 = Author(person_id=1001, name="Alice Researcher", ascii_name="Alice Researcher",
|
||||||
|
affiliation="ExampleCorp", fetched_at=datetime.now(timezone.utc).isoformat())
|
||||||
|
author2 = Author(person_id=1002, name="Bob Engineer", ascii_name="Bob Engineer",
|
||||||
|
affiliation="TestLabs", fetched_at=datetime.now(timezone.utc).isoformat())
|
||||||
|
author3 = Author(person_id=1003, name="Carol Scientist", ascii_name="Carol Scientist",
|
||||||
|
affiliation="ExampleCorp", fetched_at=datetime.now(timezone.utc).isoformat())
|
||||||
|
for a in [author1, author2, author3]:
|
||||||
|
db.upsert_author(a)
|
||||||
|
|
||||||
|
db.upsert_draft_author("draft-alpha-agent-comm", 1001, 1, "ExampleCorp")
|
||||||
|
db.upsert_draft_author("draft-alpha-agent-comm", 1002, 2, "TestLabs")
|
||||||
|
db.upsert_draft_author("draft-beta-ml-traffic", 1002, 1, "TestLabs")
|
||||||
|
db.upsert_draft_author("draft-gamma-agent-id", 1001, 1, "ExampleCorp")
|
||||||
|
db.upsert_draft_author("draft-gamma-agent-id", 1003, 2, "ExampleCorp")
|
||||||
|
db.upsert_draft_author("draft-delta-safety", 1003, 1, "ExampleCorp")
|
||||||
|
|
||||||
|
# Refs
|
||||||
|
db.insert_refs("draft-alpha-agent-comm", [("rfc", "8259"), ("rfc", "9110"), ("draft", "draft-ietf-httpbis")])
|
||||||
|
db.insert_refs("draft-beta-ml-traffic", [("rfc", "8259"), ("bcp", "BCP14")])
|
||||||
|
db.insert_refs("draft-gamma-agent-id", [("rfc", "5280"), ("rfc", "8259")])
|
||||||
|
|
||||||
|
yield db
|
||||||
287
tests/test_db.py
Normal file
287
tests/test_db.py
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
"""Tests for ietf_analyzer.db.Database."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ietf_analyzer.db import Database
|
||||||
|
from ietf_analyzer.models import Author, Draft, Rating
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Table creation ----
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_tables_creates_all(tmp_db):
|
||||||
|
"""All expected tables should exist after Database initialization."""
|
||||||
|
rows = tmp_db.conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
||||||
|
).fetchall()
|
||||||
|
table_names = {r["name"] for r in rows}
|
||||||
|
expected = {
|
||||||
|
"drafts", "ratings", "embeddings", "llm_cache",
|
||||||
|
"authors", "draft_authors", "ideas", "gaps",
|
||||||
|
"draft_refs", "generated_drafts", "generation_runs",
|
||||||
|
"sources", "observatory_snapshots", "gap_history",
|
||||||
|
"annotations", "monitor_runs",
|
||||||
|
}
|
||||||
|
assert expected.issubset(table_names), f"Missing tables: {expected - table_names}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Drafts ----
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_draft_insert(tmp_db, sample_draft):
|
||||||
|
"""Inserting a new draft should make it retrievable."""
|
||||||
|
tmp_db.upsert_draft(sample_draft)
|
||||||
|
retrieved = tmp_db.get_draft(sample_draft.name)
|
||||||
|
assert retrieved is not None
|
||||||
|
assert retrieved.name == sample_draft.name
|
||||||
|
assert retrieved.title == sample_draft.title
|
||||||
|
assert retrieved.rev == sample_draft.rev
|
||||||
|
assert retrieved.pages == sample_draft.pages
|
||||||
|
assert retrieved.categories == sample_draft.categories
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_draft_update(tmp_db, sample_draft):
|
||||||
|
"""Upserting an existing draft should update its fields."""
|
||||||
|
tmp_db.upsert_draft(sample_draft)
|
||||||
|
sample_draft.title = "Updated Title"
|
||||||
|
sample_draft.rev = "03"
|
||||||
|
tmp_db.upsert_draft(sample_draft)
|
||||||
|
|
||||||
|
retrieved = tmp_db.get_draft(sample_draft.name)
|
||||||
|
assert retrieved.title == "Updated Title"
|
||||||
|
assert retrieved.rev == "03"
|
||||||
|
# Should still be only one draft
|
||||||
|
assert tmp_db.count_drafts() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_drafts_fts5(tmp_db, sample_draft):
|
||||||
|
"""FTS5 search should find drafts matching query terms."""
|
||||||
|
tmp_db.upsert_draft(sample_draft)
|
||||||
|
results = tmp_db.search_drafts("autonomous agents communicate")
|
||||||
|
assert len(results) >= 1
|
||||||
|
assert results[0].name == sample_draft.name
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_drafts_no_results(tmp_db, sample_draft):
|
||||||
|
"""FTS5 search with non-matching query should return empty list."""
|
||||||
|
tmp_db.upsert_draft(sample_draft)
|
||||||
|
results = tmp_db.search_drafts("quantum blockchain hyperledger")
|
||||||
|
assert results == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_drafts_pagination(seeded_db):
|
||||||
|
"""list_drafts should respect limit and order_by."""
|
||||||
|
all_drafts = seeded_db.list_drafts(limit=100, order_by="name ASC")
|
||||||
|
assert len(all_drafts) == 5
|
||||||
|
|
||||||
|
first_two = seeded_db.list_drafts(limit=2, order_by="name ASC")
|
||||||
|
assert len(first_two) == 2
|
||||||
|
assert first_two[0].name == "draft-alpha-agent-comm"
|
||||||
|
assert first_two[1].name == "draft-beta-ml-traffic"
|
||||||
|
|
||||||
|
|
||||||
|
def test_count_drafts(seeded_db):
|
||||||
|
"""count_drafts should return accurate count."""
|
||||||
|
assert seeded_db.count_drafts() == 5
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Ratings ----
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_rating(tmp_db, sample_draft, sample_rating):
|
||||||
|
"""Inserting a rating should make it retrievable."""
|
||||||
|
tmp_db.upsert_draft(sample_draft)
|
||||||
|
tmp_db.upsert_rating(sample_rating)
|
||||||
|
retrieved = tmp_db.get_rating(sample_rating.draft_name)
|
||||||
|
assert retrieved is not None
|
||||||
|
assert retrieved.novelty == 4
|
||||||
|
assert retrieved.relevance == 5
|
||||||
|
assert "A2A protocols" in retrieved.categories
|
||||||
|
|
||||||
|
|
||||||
|
def test_drafts_with_ratings(seeded_db):
|
||||||
|
"""drafts_with_ratings should return (Draft, Rating) pairs."""
|
||||||
|
pairs = seeded_db.drafts_with_ratings(limit=100)
|
||||||
|
assert len(pairs) == 5
|
||||||
|
for draft, rating in pairs:
|
||||||
|
assert isinstance(draft, Draft)
|
||||||
|
assert isinstance(rating, Rating)
|
||||||
|
assert draft.name == rating.draft_name
|
||||||
|
|
||||||
|
|
||||||
|
def test_drafts_without_text(tmp_db):
|
||||||
|
"""drafts_without_text should return drafts where full_text is None."""
|
||||||
|
d1 = Draft(name="draft-has-text", rev="00", title="Has Text", abstract="Abs",
|
||||||
|
time="2025-01-01", full_text="Some text here")
|
||||||
|
d2 = Draft(name="draft-no-text", rev="00", title="No Text", abstract="Abs",
|
||||||
|
time="2025-01-01", full_text=None)
|
||||||
|
tmp_db.upsert_draft(d1)
|
||||||
|
tmp_db.upsert_draft(d2)
|
||||||
|
|
||||||
|
missing = tmp_db.drafts_without_text()
|
||||||
|
names = [d.name for d in missing]
|
||||||
|
assert "draft-no-text" in names
|
||||||
|
assert "draft-has-text" not in names
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Ideas ----
|
||||||
|
|
||||||
|
|
||||||
|
def test_insert_ideas(seeded_db):
|
||||||
|
"""Bulk idea insertion should work correctly."""
|
||||||
|
ideas = [
|
||||||
|
{"title": "New Idea A", "description": "Desc A", "type": "mechanism"},
|
||||||
|
{"title": "New Idea B", "description": "Desc B", "type": "protocol"},
|
||||||
|
]
|
||||||
|
seeded_db.insert_ideas("draft-epsilon-discovery", ideas)
|
||||||
|
retrieved = seeded_db.get_ideas_for_draft("draft-epsilon-discovery")
|
||||||
|
assert len(retrieved) == 2
|
||||||
|
assert retrieved[0]["title"] == "New Idea A"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_ideas_for_draft(seeded_db):
|
||||||
|
"""Retrieving ideas for a specific draft should return correct data."""
|
||||||
|
ideas = seeded_db.get_ideas_for_draft("draft-alpha-agent-comm")
|
||||||
|
assert len(ideas) == 2
|
||||||
|
titles = {i["title"] for i in ideas}
|
||||||
|
assert "Agent Handshake" in titles
|
||||||
|
assert "Capability Negotiation" in titles
|
||||||
|
|
||||||
|
|
||||||
|
def test_insert_ideas_replaces_existing(seeded_db):
|
||||||
|
"""Inserting ideas for a draft should replace existing ideas."""
|
||||||
|
seeded_db.insert_ideas("draft-alpha-agent-comm", [
|
||||||
|
{"title": "Replacement Idea", "description": "Replaced", "type": "pattern"},
|
||||||
|
])
|
||||||
|
ideas = seeded_db.get_ideas_for_draft("draft-alpha-agent-comm")
|
||||||
|
assert len(ideas) == 1
|
||||||
|
assert ideas[0]["title"] == "Replacement Idea"
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Gaps ----
|
||||||
|
|
||||||
|
|
||||||
|
def test_insert_gaps(tmp_db):
|
||||||
|
"""Gap insertion should work correctly."""
|
||||||
|
gaps = [
|
||||||
|
{"topic": "Agent Auth Gap", "description": "No standard auth for agents",
|
||||||
|
"category": "Agent identity/auth", "severity": "critical", "evidence": "Only 2 drafts address this"},
|
||||||
|
{"topic": "Monitoring Gap", "description": "No agent monitoring standard",
|
||||||
|
"category": "Autonomous netops", "severity": "high", "evidence": "Zero drafts cover monitoring"},
|
||||||
|
]
|
||||||
|
tmp_db.insert_gaps(gaps)
|
||||||
|
retrieved = tmp_db.all_gaps()
|
||||||
|
assert len(retrieved) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_gaps(tmp_db):
|
||||||
|
"""all_gaps should return all inserted gaps with correct fields."""
|
||||||
|
gaps = [
|
||||||
|
{"topic": "Test Gap", "description": "Test description",
|
||||||
|
"category": "Other", "severity": "medium", "evidence": "Test evidence"},
|
||||||
|
]
|
||||||
|
tmp_db.insert_gaps(gaps)
|
||||||
|
result = tmp_db.all_gaps()
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["topic"] == "Test Gap"
|
||||||
|
assert result[0]["severity"] == "medium"
|
||||||
|
assert result[0]["evidence"] == "Test evidence"
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Embeddings ----
|
||||||
|
|
||||||
|
|
||||||
|
def test_store_embedding(tmp_db, sample_draft):
|
||||||
|
"""Storing an embedding should persist the numpy vector."""
|
||||||
|
tmp_db.upsert_draft(sample_draft)
|
||||||
|
vec = np.array([0.1, 0.2, 0.3, 0.4, 0.5], dtype=np.float32)
|
||||||
|
tmp_db.store_embedding(sample_draft.name, "test-model", vec)
|
||||||
|
|
||||||
|
retrieved = tmp_db.get_embedding(sample_draft.name)
|
||||||
|
assert retrieved is not None
|
||||||
|
np.testing.assert_array_almost_equal(retrieved, vec)
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_embeddings(tmp_db, sample_draft):
|
||||||
|
"""all_embeddings should return dict of {name: ndarray}."""
|
||||||
|
tmp_db.upsert_draft(sample_draft)
|
||||||
|
vec = np.array([1.0, 2.0, 3.0], dtype=np.float32)
|
||||||
|
tmp_db.store_embedding(sample_draft.name, "test-model", vec)
|
||||||
|
|
||||||
|
all_emb = tmp_db.all_embeddings()
|
||||||
|
assert sample_draft.name in all_emb
|
||||||
|
np.testing.assert_array_almost_equal(all_emb[sample_draft.name], vec)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- LLM Cache ----
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_response(tmp_db):
|
||||||
|
"""Caching an LLM response should be retrievable by draft_name + hash."""
|
||||||
|
tmp_db.cache_response(
|
||||||
|
"draft-test", "abc123hash", "claude-test",
|
||||||
|
"prompt text", '{"result": "ok"}', 100, 50,
|
||||||
|
)
|
||||||
|
cached = tmp_db.get_cached_response("draft-test", "abc123hash")
|
||||||
|
assert cached is not None
|
||||||
|
assert json.loads(cached) == {"result": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_response_miss(tmp_db):
|
||||||
|
"""Cache miss should return None."""
|
||||||
|
result = tmp_db.get_cached_response("nonexistent", "badhash")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Refs ----
|
||||||
|
|
||||||
|
|
||||||
|
def test_insert_refs(seeded_db):
|
||||||
|
"""Reference insertion should work and be queryable."""
|
||||||
|
refs = seeded_db.get_refs_for_draft("draft-alpha-agent-comm")
|
||||||
|
assert len(refs) == 3
|
||||||
|
ref_types = {r[0] for r in refs}
|
||||||
|
assert "rfc" in ref_types
|
||||||
|
assert "draft" in ref_types
|
||||||
|
|
||||||
|
|
||||||
|
def test_top_refs(seeded_db):
|
||||||
|
"""top_referenced should return most commonly cited RFCs."""
|
||||||
|
top = seeded_db.top_referenced(ref_type="rfc", limit=5)
|
||||||
|
# RFC 8259 is referenced by 3 drafts
|
||||||
|
assert len(top) > 0
|
||||||
|
assert top[0][0] == "8259"
|
||||||
|
assert top[0][1] == 3
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Authors ----
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_authors_for_draft(seeded_db):
|
||||||
|
"""Getting authors for a draft should return correct Author objects."""
|
||||||
|
authors = seeded_db.get_authors_for_draft("draft-alpha-agent-comm")
|
||||||
|
assert len(authors) == 2
|
||||||
|
names = {a.name for a in authors}
|
||||||
|
assert "Alice Researcher" in names
|
||||||
|
assert "Bob Engineer" in names
|
||||||
|
|
||||||
|
|
||||||
|
def test_author_count(seeded_db):
|
||||||
|
"""author_count should return the total number of unique authors."""
|
||||||
|
assert seeded_db.author_count() == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_top_authors(seeded_db):
|
||||||
|
"""top_authors should return authors sorted by draft count."""
|
||||||
|
top = seeded_db.top_authors(limit=10)
|
||||||
|
# Alice and Bob each have 2 drafts, Carol has 2 as well
|
||||||
|
assert len(top) > 0
|
||||||
|
# First author should have most drafts
|
||||||
|
name, aff, count, draft_names = top[0]
|
||||||
|
assert count >= 2
|
||||||
190
tests/test_models.py
Normal file
190
tests/test_models.py
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
"""Tests for ietf_analyzer.models and ietf_analyzer.config."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ietf_analyzer.models import Draft, Rating, Author, normalize_category, CATEGORY_NORMALIZE
|
||||||
|
from ietf_analyzer.config import Config, DEFAULT_KEYWORDS
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Rating ----
|
||||||
|
|
||||||
|
|
||||||
|
def test_rating_composite_score():
|
||||||
|
"""Composite score should use weighted average formula."""
|
||||||
|
r = Rating(
|
||||||
|
draft_name="test", novelty=4, maturity=3, overlap=2,
|
||||||
|
momentum=3, relevance=5, summary="test",
|
||||||
|
)
|
||||||
|
# Expected: 4*0.30 + 5*0.25 + 3*0.20 + 3*0.15 + (6-2)*0.10
|
||||||
|
expected = 4 * 0.30 + 5 * 0.25 + 3 * 0.20 + 3 * 0.15 + (6 - 2) * 0.10
|
||||||
|
assert abs(r.composite_score - expected) < 0.001
|
||||||
|
|
||||||
|
|
||||||
|
def test_rating_composite_score_all_ones():
|
||||||
|
"""Composite score with all 1s should be the minimum."""
|
||||||
|
r = Rating(
|
||||||
|
draft_name="test", novelty=1, maturity=1, overlap=5,
|
||||||
|
momentum=1, relevance=1, summary="test",
|
||||||
|
)
|
||||||
|
expected = 1 * 0.30 + 1 * 0.25 + 1 * 0.20 + 1 * 0.15 + (6 - 5) * 0.10
|
||||||
|
assert abs(r.composite_score - expected) < 0.001
|
||||||
|
|
||||||
|
|
||||||
|
def test_rating_composite_score_all_fives():
|
||||||
|
"""Composite score with all 5s (except overlap=1 for best)."""
|
||||||
|
r = Rating(
|
||||||
|
draft_name="test", novelty=5, maturity=5, overlap=1,
|
||||||
|
momentum=5, relevance=5, summary="test",
|
||||||
|
)
|
||||||
|
expected = 5 * 0.30 + 5 * 0.25 + 5 * 0.20 + 5 * 0.15 + (6 - 1) * 0.10
|
||||||
|
assert abs(r.composite_score - expected) < 0.001
|
||||||
|
assert r.composite_score == 5.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Draft ----
|
||||||
|
|
||||||
|
|
||||||
|
def test_draft_datatracker_url():
|
||||||
|
"""datatracker_url should construct the correct URL."""
|
||||||
|
d = Draft(name="draft-example-test", rev="00", title="Test", abstract="", time="2025-01-01")
|
||||||
|
assert d.datatracker_url == "https://datatracker.ietf.org/doc/draft-example-test/"
|
||||||
|
|
||||||
|
|
||||||
|
def test_draft_text_url():
|
||||||
|
"""text_url should construct the correct URL with revision."""
|
||||||
|
d = Draft(name="draft-example-test", rev="03", title="Test", abstract="", time="2025-01-01")
|
||||||
|
assert d.text_url == "https://www.ietf.org/archive/id/draft-example-test-03.txt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_draft_defaults():
|
||||||
|
"""Draft should have sensible defaults for optional fields."""
|
||||||
|
d = Draft(name="draft-minimal", rev="00", title="Min", abstract="", time="2025-01-01")
|
||||||
|
assert d.dt_id is None
|
||||||
|
assert d.pages is None
|
||||||
|
assert d.words is None
|
||||||
|
assert d.group is None
|
||||||
|
assert d.full_text is None
|
||||||
|
assert d.categories == []
|
||||||
|
assert d.tags == []
|
||||||
|
assert d.states == []
|
||||||
|
assert d.source == "ietf"
|
||||||
|
|
||||||
|
|
||||||
|
def test_draft_date_property():
|
||||||
|
"""Draft.date should return just the date portion of time."""
|
||||||
|
d = Draft(name="test", rev="00", title="T", abstract="", time="2025-06-15T12:00:00+00:00")
|
||||||
|
assert d.date == "2025-06-15"
|
||||||
|
|
||||||
|
|
||||||
|
def test_draft_date_empty():
|
||||||
|
"""Draft.date should return empty string if time is None."""
|
||||||
|
d = Draft(name="test", rev="00", title="T", abstract="", time=None)
|
||||||
|
assert d.date == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---- normalize_category ----
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_category():
|
||||||
|
"""Known verbose category names should be normalized to short forms."""
|
||||||
|
assert normalize_category("Agent-to-agent communication protocols") == "A2A protocols"
|
||||||
|
assert normalize_category("AI safety / guardrails / alignment") == "AI safety/alignment"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_category_passthrough():
|
||||||
|
"""Unknown category names should pass through unchanged."""
|
||||||
|
assert normalize_category("A2A protocols") == "A2A protocols"
|
||||||
|
assert normalize_category("Some Unknown Category") == "Some Unknown Category"
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Config ----
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_load_defaults():
|
||||||
|
"""Config without a file should use defaults."""
|
||||||
|
cfg = Config()
|
||||||
|
assert cfg.ollama_url == "http://localhost:11434"
|
||||||
|
assert cfg.claude_model != ""
|
||||||
|
assert cfg.fetch_delay == 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_save_and_load(tmp_path):
|
||||||
|
"""Config should roundtrip through save/load."""
|
||||||
|
cfg = Config(
|
||||||
|
data_dir=str(tmp_path),
|
||||||
|
db_path=str(tmp_path / "test.db"),
|
||||||
|
claude_model="claude-test-model",
|
||||||
|
)
|
||||||
|
# Save to the default config path (override it)
|
||||||
|
config_file = tmp_path / "config.json"
|
||||||
|
config_file.write_text(json.dumps({
|
||||||
|
"data_dir": str(tmp_path),
|
||||||
|
"db_path": str(tmp_path / "test.db"),
|
||||||
|
"claude_model": "claude-test-model",
|
||||||
|
"ollama_url": "http://localhost:11434",
|
||||||
|
"search_keywords": ["agent", "ai-agent"],
|
||||||
|
}))
|
||||||
|
|
||||||
|
# Verify roundtrip by reading back
|
||||||
|
data = json.loads(config_file.read_text())
|
||||||
|
loaded = Config(**{k: v for k, v in data.items() if k in Config.__dataclass_fields__})
|
||||||
|
assert loaded.claude_model == "claude-test-model"
|
||||||
|
assert loaded.db_path == str(tmp_path / "test.db")
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_search_keywords():
|
||||||
|
"""Default config should have the expected search keywords."""
|
||||||
|
cfg = Config()
|
||||||
|
assert "agent" in cfg.search_keywords
|
||||||
|
assert "mcp" in cfg.search_keywords
|
||||||
|
assert "agentic" in cfg.search_keywords
|
||||||
|
assert len(cfg.search_keywords) == len(DEFAULT_KEYWORDS)
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_config_file(monkeypatch, tmp_path):
|
||||||
|
"""Point CONFIG_FILE to a non-existent path so tests use defaults."""
|
||||||
|
import ietf_analyzer.config as config_mod
|
||||||
|
monkeypatch.setattr(config_mod, "CONFIG_FILE", tmp_path / "config.json")
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_env_var_override(tmp_path, monkeypatch):
|
||||||
|
"""Environment variables should override config file values."""
|
||||||
|
_patch_config_file(monkeypatch, tmp_path)
|
||||||
|
monkeypatch.setenv("IETF_ANALYZER_DB_PATH", str(tmp_path / "env.db"))
|
||||||
|
monkeypatch.setenv("IETF_ANALYZER_CLAUDE_MODEL", "claude-from-env")
|
||||||
|
monkeypatch.setenv("IETF_ANALYZER_OLLAMA_URL", "http://remote:11434")
|
||||||
|
|
||||||
|
cfg = Config.load()
|
||||||
|
assert cfg.db_path == str(tmp_path / "env.db")
|
||||||
|
assert cfg.claude_model == "claude-from-env"
|
||||||
|
assert cfg.ollama_url == "http://remote:11434"
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_validation_bad_model(tmp_path, monkeypatch):
|
||||||
|
"""Empty claude_model should raise ValueError."""
|
||||||
|
_patch_config_file(monkeypatch, tmp_path)
|
||||||
|
monkeypatch.setenv("IETF_ANALYZER_CLAUDE_MODEL", "")
|
||||||
|
with pytest.raises(ValueError, match="claude_model"):
|
||||||
|
Config.load()
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_validation_bad_url(tmp_path, monkeypatch):
|
||||||
|
"""Non-URL ollama_url should raise ValueError."""
|
||||||
|
_patch_config_file(monkeypatch, tmp_path)
|
||||||
|
monkeypatch.setenv("IETF_ANALYZER_OLLAMA_URL", "not-a-url")
|
||||||
|
with pytest.raises(ValueError, match="ollama_url"):
|
||||||
|
Config.load()
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_validation_bad_db_path(tmp_path, monkeypatch):
|
||||||
|
"""db_path with non-existent parent directory should raise ValueError."""
|
||||||
|
_patch_config_file(monkeypatch, tmp_path)
|
||||||
|
monkeypatch.setenv("IETF_ANALYZER_DB_PATH", "/nonexistent/dir/test.db")
|
||||||
|
with pytest.raises(ValueError, match="db_path"):
|
||||||
|
Config.load()
|
||||||
158
tests/test_web_data.py
Normal file
158
tests/test_web_data.py
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
"""Tests for src/webui/data.py data access functions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from functools import wraps
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Ensure webui is importable
|
||||||
|
_project_root = Path(__file__).resolve().parent.parent
|
||||||
|
if str(_project_root / "src") not in sys.path:
|
||||||
|
sys.path.insert(0, str(_project_root / "src"))
|
||||||
|
|
||||||
|
from webui.data import (
|
||||||
|
get_overview_stats,
|
||||||
|
get_category_counts,
|
||||||
|
get_drafts_page,
|
||||||
|
get_draft_detail,
|
||||||
|
get_ideas_by_type,
|
||||||
|
get_all_gaps,
|
||||||
|
get_timeline_data,
|
||||||
|
get_top_authors,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _skip_on_missing_module(fn):
|
||||||
|
"""Decorator that skips tests when webui.data references unavailable modules."""
|
||||||
|
@wraps(fn)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
try:
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
except (ModuleNotFoundError, AttributeError) as e:
|
||||||
|
pytest.skip(f"webui.data depends on module not in this worktree: {e}")
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_overview_stats(seeded_db):
|
||||||
|
"""Overview stats should return correct counts from seeded data."""
|
||||||
|
stats = get_overview_stats(seeded_db)
|
||||||
|
assert stats["total_drafts"] == 5
|
||||||
|
assert stats["rated_count"] == 5
|
||||||
|
assert stats["author_count"] == 3
|
||||||
|
# 2 + 1 + 1 = 4 ideas in seeded data
|
||||||
|
assert stats["idea_count"] == 4
|
||||||
|
assert stats["gap_count"] == 0
|
||||||
|
assert "input_tokens" in stats
|
||||||
|
assert "output_tokens" in stats
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_category_counts(seeded_db):
|
||||||
|
"""Category counts should reflect the seeded ratings."""
|
||||||
|
counts = get_category_counts(seeded_db)
|
||||||
|
assert isinstance(counts, dict)
|
||||||
|
assert "A2A protocols" in counts
|
||||||
|
assert counts["A2A protocols"] == 1
|
||||||
|
assert "ML traffic mgmt" in counts
|
||||||
|
|
||||||
|
|
||||||
|
@_skip_on_missing_module
|
||||||
|
def test_get_drafts_page_basic(seeded_db):
|
||||||
|
"""Drafts page should return paginated results."""
|
||||||
|
result = get_drafts_page(seeded_db, page=1, per_page=3)
|
||||||
|
assert len(result["drafts"]) == 3
|
||||||
|
assert result["total"] == 5
|
||||||
|
assert result["page"] == 1
|
||||||
|
assert result["per_page"] == 3
|
||||||
|
assert result["pages"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
@_skip_on_missing_module
|
||||||
|
def test_get_drafts_page_with_category_filter(seeded_db):
|
||||||
|
"""Filtering by category should narrow results."""
|
||||||
|
result = get_drafts_page(seeded_db, category="A2A protocols")
|
||||||
|
assert result["total"] == 1
|
||||||
|
assert result["drafts"][0]["categories"] == ["A2A protocols"]
|
||||||
|
|
||||||
|
|
||||||
|
@_skip_on_missing_module
|
||||||
|
def test_get_drafts_page_with_search_filter(seeded_db):
|
||||||
|
"""Text search should filter by name/title/summary."""
|
||||||
|
result = get_drafts_page(seeded_db, search="alpha")
|
||||||
|
assert result["total"] == 1
|
||||||
|
assert "alpha" in result["drafts"][0]["name"]
|
||||||
|
|
||||||
|
|
||||||
|
@_skip_on_missing_module
|
||||||
|
def test_get_drafts_page_empty_search(seeded_db):
|
||||||
|
"""Search for non-matching term should return 0 results."""
|
||||||
|
result = get_drafts_page(seeded_db, search="zzzznonexistent")
|
||||||
|
assert result["total"] == 0
|
||||||
|
assert result["drafts"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@_skip_on_missing_module
|
||||||
|
def test_get_draft_detail(seeded_db):
|
||||||
|
"""Draft detail should include draft, rating, authors, ideas, refs."""
|
||||||
|
detail = get_draft_detail(seeded_db, "draft-alpha-agent-comm")
|
||||||
|
assert detail is not None
|
||||||
|
assert detail["name"] == "draft-alpha-agent-comm"
|
||||||
|
assert detail["title"] == "Alpha Agent Communication"
|
||||||
|
assert "rating" in detail
|
||||||
|
assert detail["rating"]["novelty"] == 4
|
||||||
|
assert len(detail["authors"]) == 2
|
||||||
|
assert len(detail["ideas"]) == 2
|
||||||
|
assert len(detail["refs"]) == 3
|
||||||
|
|
||||||
|
|
||||||
|
@_skip_on_missing_module
|
||||||
|
def test_get_draft_detail_not_found(seeded_db):
|
||||||
|
"""Draft detail for non-existent draft should return None."""
|
||||||
|
assert get_draft_detail(seeded_db, "draft-nonexistent") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_ideas_by_type(seeded_db):
|
||||||
|
"""Ideas by type should group and count correctly."""
|
||||||
|
result = get_ideas_by_type(seeded_db)
|
||||||
|
assert result["total"] == 4
|
||||||
|
assert "by_type" in result
|
||||||
|
assert isinstance(result["by_type"], dict)
|
||||||
|
# We have protocol, mechanism, extension types
|
||||||
|
assert "protocol" in result["by_type"] or "mechanism" in result["by_type"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_all_gaps_empty(seeded_db):
|
||||||
|
"""With no gaps inserted, should return empty list."""
|
||||||
|
gaps = get_all_gaps(seeded_db)
|
||||||
|
assert gaps == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_all_gaps_with_data(seeded_db):
|
||||||
|
"""After inserting gaps, should return them."""
|
||||||
|
seeded_db.insert_gaps([
|
||||||
|
{"topic": "Gap A", "description": "Desc A", "severity": "high", "evidence": "Ev A"},
|
||||||
|
])
|
||||||
|
gaps = get_all_gaps(seeded_db)
|
||||||
|
assert len(gaps) == 1
|
||||||
|
assert gaps[0]["topic"] == "Gap A"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_timeline_data(seeded_db):
|
||||||
|
"""Timeline data should group drafts by month."""
|
||||||
|
data = get_timeline_data(seeded_db)
|
||||||
|
assert "months" in data
|
||||||
|
assert "series" in data
|
||||||
|
assert "categories" in data
|
||||||
|
# Seeded drafts span Jan-May 2025
|
||||||
|
assert len(data["months"]) >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_top_authors(seeded_db):
|
||||||
|
"""Top authors should return ranked list with draft counts."""
|
||||||
|
authors = get_top_authors(seeded_db, limit=10)
|
||||||
|
assert len(authors) >= 1
|
||||||
|
assert "name" in authors[0]
|
||||||
|
assert "draft_count" in authors[0]
|
||||||
|
assert authors[0]["draft_count"] >= 2
|
||||||
Reference in New Issue
Block a user