From a76ad5258b50ae2daa3bff5dca2a55d32a8fcaae Mon Sep 17 00:00:00 2001 From: ThinkStorm Date: Thu, 27 Aug 2026 16:13:29 -0700 Subject: [PATCH] Make Gitea publishing claimant controlled --- README.md | 6 +- tests/test_gitea_claimant_publishing.py | 191 ++++++++++++++++++++ tests/test_youtube_work_track.py | 13 +- thinkstorm/api/ideas.py | 222 ++++++++++++++++++++---- thinkstorm/auth.py | 18 +- thinkstorm/processors/pipeline.py | 45 +++-- thinkstorm/services/gitea.py | 153 +++++++++------- thinkstorm/static/js/app.js | 148 ++++++++++++---- thinkstorm/templates/base.html | 2 +- thinkstorm/templates/idea_detail.html | 214 +++++++++++++++++------ 10 files changed, 797 insertions(+), 215 deletions(-) create mode 100644 tests/test_gitea_claimant_publishing.py diff --git a/README.md b/README.md index 057c060..bdd10b0 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,8 @@ ThinkStorm captures unformed ideas with zero friction, preserves original submis - **Deep Technical Feasibility & Risk Critique**: Generates feasibility scores (0–10), technical constraints, implementation roadmaps, and risk matrices. ### 3. Canonical Gitea Project Repositories -- Every incubated idea is automatically provisioned as a full Git repository under the **`thinkstorm`** organization on Gitea (e.g. `https://git.labyricorn.com/thinkstorm/ts-0032`). +- Ideas and generated Work Track artifacts remain local until a Gitea-linked user claims the idea and deliberately publishes it. +- Claimants can publish or re-sync an idempotent repository under the **`thinkstorm`** organization (for example, `https://git.labyricorn.com/thinkstorm/ts-0032`) and receive write access for normal Git pushes. - Repositories include: - `README.md`: Executive summary, prompt quotation, reference image metadata, lifecycle badge, and project structure guide. - `metadata.json`: Machine-readable metadata schema. @@ -76,7 +77,7 @@ graph TD P4 -->|Web Search| SearXNG[SearXNG & Perplexica] P2 & P3 & P4 & P5 -->|LLM Synthesis| Omni - Queue -->|Push Dossier Repo| Gitea[Gitea Project Host] + Web -->|Claimant-triggered publish| Gitea[Gitea Project Host] Queue -->|Push Gist Snippets| OpenGist[OpenGist Service] ``` @@ -220,4 +221,3 @@ python3 -m pytest tests/ -v ## 📄 License & Attribution Developed by **Labyricorn**. Licensed under the [MIT License](LICENSE). - diff --git a/tests/test_gitea_claimant_publishing.py b/tests/test_gitea_claimant_publishing.py new file mode 100644 index 0000000..c0d12ee --- /dev/null +++ b/tests/test_gitea_claimant_publishing.py @@ -0,0 +1,191 @@ +import asyncio + +import pytest +from fastapi import HTTPException + +from thinkstorm import database +from thinkstorm import auth +from thinkstorm.api import ideas as ideas_api +from thinkstorm.models import User, UserRole +from thinkstorm.services import gitea as gitea_module + + +@pytest.fixture +def isolated_db(tmp_path, monkeypatch): + monkeypatch.setattr(database, "DB_PATH", str(tmp_path / "claimant-gitea.db")) + database.init_db() + return tmp_path + + +def _insert_idea(idea_id, claimed_by=None): + now = database.get_utc_now() + with database.get_db() as conn: + conn.execute( + """ + INSERT INTO ideas + (id, original_text, submitted_at, title, summary, lifecycle_state, + processing_state, claimed_by, claimed_at, created_at, updated_at) + VALUES (?, 'Original idea', ?, 'Claimant Publishing', 'Summary', ?, + 'IDLE', ?, ?, ?, ?) + """, + ( + idea_id, + now, + "CLAIMED" if claimed_by else "AVAILABLE", + claimed_by, + now if claimed_by else None, + now, + now, + ), + ) + + +def _user(username, role=UserRole.USER): + return User(id=10, username=username, password_hash="", role=role) + + +def test_unclaimed_idea_cannot_be_published(isolated_db): + _insert_idea("TS-PUB1") + + with pytest.raises(HTTPException) as exc: + asyncio.run( + ideas_api.sync_idea_to_gitea( + "TS-PUB1", current_user=_user("researcher") + ) + ) + + assert exc.value.status_code == 409 + + +def test_claimant_must_have_verified_gitea_identity(isolated_db): + _insert_idea("TS-PUB2", claimed_by="researcher") + + with pytest.raises(HTTPException) as exc: + asyncio.run( + ideas_api.sync_idea_to_gitea( + "TS-PUB2", current_user=_user("researcher") + ) + ) + + assert exc.value.status_code == 403 + assert "sign in with Gitea" in exc.value.detail + + +def test_only_claimant_can_publish(isolated_db): + _insert_idea("TS-PUB3", claimed_by="researcher") + with database.get_db() as conn: + conn.execute( + "UPDATE users SET gitea_id = 1234 WHERE username = 'researcher'" + ) + + with pytest.raises(HTTPException) as exc: + asyncio.run( + ideas_api.sync_idea_to_gitea( + "TS-PUB3", current_user=_user("someone-else") + ) + ) + + assert exc.value.status_code == 403 + + +def test_claimant_publish_is_idempotent_and_grants_write_access( + isolated_db, monkeypatch +): + _insert_idea("TS-PUB4", claimed_by="researcher") + with database.get_db() as conn: + conn.execute( + "UPDATE users SET gitea_id = 1234 WHERE username = 'researcher'" + ) + + calls = [] + + async def fake_publish(**kwargs): + calls.append(kwargs) + return { + "repo_name": "thinkstorm/ts-pub4", + "repo_url": "https://git.example/thinkstorm/ts-pub4", + "clone_url": "https://git.example/thinkstorm/ts-pub4.git", + "ssh_url": "ssh://git@git.example/thinkstorm/ts-pub4.git", + "local_path": str(isolated_db / "TS-PUB4"), + "published": True, + } + + monkeypatch.setattr( + ideas_api.gitea_svc, "persist_idea_dossier_repo", fake_publish + ) + + first = asyncio.run( + ideas_api.sync_idea_to_gitea( + "TS-PUB4", current_user=_user("researcher") + ) + ) + second = asyncio.run( + ideas_api.sync_idea_to_gitea( + "TS-PUB4", current_user=_user("researcher") + ) + ) + + assert first["gitea_repo_name"] == "thinkstorm/ts-pub4" + assert second["gitea_repo_url"].endswith("/ts-pub4") + assert all(call["publish_remote"] is True for call in calls) + assert all(call["collaborator_username"] == "researcher" for call in calls) + + with database.get_db() as conn: + idea = conn.execute( + "SELECT gitea_repo_name, gitea_repo_url FROM ideas WHERE id = 'TS-PUB4'" + ).fetchone() + resources = conn.execute( + """ + SELECT COUNT(*) AS count FROM external_resources + WHERE idea_id = 'TS-PUB4' AND resource_type = 'GITEA_DOSSIER' + """ + ).fetchone()["count"] + + assert idea["gitea_repo_name"] == "thinkstorm/ts-pub4" + assert resources == 1 + + +def test_oauth_links_existing_local_account_to_gitea(isolated_db): + linked_user = auth.get_or_create_gitea_user( + {"login": "researcher", "id": 9876, "is_admin": False} + ) + + with database.get_db() as conn: + row = conn.execute( + "SELECT gitea_id FROM users WHERE username = 'researcher'" + ).fetchone() + + assert linked_user.username == "researcher" + assert row["gitea_id"] == 9876 + + +def test_local_only_dossier_persistence_never_contacts_gitea( + isolated_db, monkeypatch +): + artifact_root = isolated_db / "artifacts" + monkeypatch.setattr(gitea_module, "ARTIFACTS_DIR", artifact_root) + adapter = gitea_module.GiteaAdapter(api_token="configured-token") + + def unexpected_remote_call(*_args, **_kwargs): + raise AssertionError("local persistence attempted a remote Gitea call") + + monkeypatch.setattr(adapter, "_ensure_repo", unexpected_remote_call) + + result = asyncio.run( + adapter.persist_idea_dossier_repo( + idea_id="TS-LOCAL", + title="Local only", + summary="Summary", + original_text="Original", + categories=[], + tags=[], + lifecycle_state="AVAILABLE", + research_docs={"analysis.md": "Research"}, + outputs={}, + provenance_runs=[], + publish_remote=False, + ) + ) + + assert result["published"] is False + assert (artifact_root / "TS-LOCAL" / "README.md").exists() diff --git a/tests/test_youtube_work_track.py b/tests/test_youtube_work_track.py index ad12cf2..c23b9de 100644 --- a/tests/test_youtube_work_track.py +++ b/tests/test_youtube_work_track.py @@ -79,12 +79,18 @@ def test_youtube_work_track_generates_three_versioned_artifacts(tmp_path, monkey "duration_ms": 1, } - async def fake_persist(*_args, **_kwargs): + gitea_persist_options = {} + + async def fake_gitea_persist(*_args, **kwargs): + gitea_persist_options.update(kwargs) + return None + + async def fake_opengist_persist(*_args, **_kwargs): return None monkeypatch.setattr(pipeline.omniroute_svc, "chat_completion", fake_completion) - monkeypatch.setattr(pipeline.gitea_svc, "persist_work_track_outputs", fake_persist) - monkeypatch.setattr(pipeline.opengist_svc, "persist_work_track_outputs", fake_persist) + monkeypatch.setattr(pipeline.gitea_svc, "persist_work_track_outputs", fake_gitea_persist) + monkeypatch.setattr(pipeline.opengist_svc, "persist_work_track_outputs", fake_opengist_persist) asyncio.run(pipeline.execute_work_track_workflow("WT-YT01")) @@ -111,3 +117,4 @@ def test_youtube_work_track_generates_three_versioned_artifacts(tmp_path, monkey assert outputs[1]["content"].startswith("# Outline") assert outputs[2]["content"].startswith("# Script") assert state == "COMPLETED" + assert gitea_persist_options["publish_remote"] is False diff --git a/thinkstorm/api/ideas.py b/thinkstorm/api/ideas.py index b01dcdf..3604857 100644 --- a/thinkstorm/api/ideas.py +++ b/thinkstorm/api/ideas.py @@ -35,6 +35,32 @@ opengist_svc = OpenGistAdapter() # Rate limiting sliding window in-memory cache: ip -> list of timestamps SUBMISSION_IP_LOG: Dict[str, List[float]] = {} + +def _resolve_gitea_claimant(conn, idea, current_user: User) -> str: + """Authorizes claimant-controlled publication and returns the Gitea username.""" + claimed_by = idea["claimed_by"] + if not claimed_by: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="The idea must be claimed before it can be published to Gitea." + ) + if claimed_by != current_user.username and current_user.role != UserRole.ADMIN: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only the current claimant can publish this idea to Gitea." + ) + + claimant = conn.execute( + "SELECT username, gitea_id FROM users WHERE username = ?", + (claimed_by,) + ).fetchone() + if not claimant or claimant["gitea_id"] is None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="The claimant must sign in with Gitea before publishing this idea." + ) + return claimant["username"] + class IdeaSubmissionRequest(BaseModel): text: str @@ -378,6 +404,86 @@ async def get_idea_detail(idea_id: str, current_user: User = Depends(require_aut except Exception: sub_img = None + # Build multi-version research stages + stage_definitions = { + "IMAGE_CONTEXT": {"name": "Visual & Image Context", "doc_name": "image-context.md", "icon": "🖼️"}, + "PRIOR_ART": {"name": "Prior Art & Alternatives", "doc_name": "prior-art.md", "icon": "🔍"}, + "RESEARCH": {"name": "Deep Research Synthesis", "doc_name": "analysis.md", "icon": "📚"}, + "FEASIBILITY": {"name": "Feasibility & Critique", "doc_name": "feasibility.md", "icon": "⚙️"} + } + + research_stages = {} + for stage_key, defn in stage_definitions.items(): + stage_runs = [ + rn for rn in provenance + if rn["stage"] == stage_key and rn["output_data"] and rn["output_data"].get("content") + ] + stage_runs_sorted = sorted(stage_runs, key=lambda x: x["started_at"] or "") + v_list = [] + for idx, r_item in enumerate(stage_runs_sorted, start=1): + v_obj = { + "version": idx, + "is_latest": False, + "run_id": r_item["id"], + "processor_name": r_item["processor_name"], + "model": r_item["resolved_model"] or "Default Policy", + "provider": r_item["resolved_provider"] or "", + "tokens": { + "input": r_item["input_tokens"], + "output": r_item["output_tokens"], + "total": r_item["total_tokens"] + }, + "started_at": r_item["started_at"], + "completed_at": r_item["completed_at"], + "duration_ms": r_item["duration_ms"], + "prompt_id": r_item["prompt_id"], + "prompt_version": r_item["prompt_version"], + "artifact_path": r_item["output_artifact"] or f"research/{defn['doc_name']}", + "content": r_item["output_data"].get("content", "") + } + v_list.append(v_obj) + + if v_list: + v_list[-1]["is_latest"] = True + latest_version = v_list[-1] + else: + latest_version = None + + research_stages[stage_key] = { + "stage": stage_key, + "name": defn["name"], + "doc_name": defn["doc_name"], + "icon": defn["icon"], + "versions": v_list, + "latest": latest_version, + "total_versions": len(v_list) + } + + claimant_gitea_linked = False + if idea_row["claimed_by"]: + claimant_row = conn.execute( + "SELECT gitea_id FROM users WHERE username = ?", + (idea_row["claimed_by"],) + ).fetchone() + claimant_gitea_linked = bool( + claimant_row and claimant_row["gitea_id"] is not None + ) + + actor_is_claimant = idea_row["claimed_by"] == current_user.username + gitea_publish_allowed = bool( + idea_row["claimed_by"] + and claimant_gitea_linked + and (actor_is_claimant or is_admin) + ) + if not idea_row["claimed_by"]: + gitea_publish_reason = "Claim this idea before publishing it to Gitea." + elif not actor_is_claimant and not is_admin: + gitea_publish_reason = "Only the current claimant can publish this idea." + elif not claimant_gitea_linked: + gitea_publish_reason = "The claimant must sign in with Gitea before publishing." + else: + gitea_publish_reason = "" + return { "id": idea_row["id"], "title": idea_row["title"], @@ -395,12 +501,17 @@ async def get_idea_detail(idea_id: str, current_user: User = Depends(require_aut "profile_id": idea_row["profile_id"], "opengist_id": idea_row["opengist_id"], "opengist_url": idea_row["opengist_url"], + "gitea_repo_name": idea_row["gitea_repo_name"] if "gitea_repo_name" in idea_row.keys() else "", + "gitea_repo_url": idea_row["gitea_repo_url"] if "gitea_repo_url" in idea_row.keys() else "", + "gitea_publish_allowed": gitea_publish_allowed, + "gitea_publish_reason": gitea_publish_reason, "submission_image": sub_img, "categories": cats, "tags": tags, "urls": urls, "work_tracks": work_tracks, "provenance": provenance, + "research_stages": research_stages, "relationships": rels, "usage_summary": { "total_tokens": total_tokens_sum, @@ -535,10 +646,20 @@ async def graduate_work_track_to_gitea(track_id: str, current_user: User = Depen if track["work_type_id"] != "CODING_PROJECT": raise HTTPException(status_code=400, detail="Only Coding Project work tracks can graduate to Gitea.") idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (track["idea_id"],)).fetchone() - if idea["claimed_by"] != current_user.username and current_user.role != UserRole.ADMIN: - raise HTTPException(status_code=403, detail="Only the claimant can graduate this project.") + claimant_username = _resolve_gitea_claimant(conn, idea, current_user) - res = await gitea_svc.graduate_project(idea["id"], idea["title"], idea["summary"]) + try: + res = await gitea_svc.graduate_project( + idea["id"], + idea["title"], + idea["summary"], + collaborator_username=claimant_username + ) + except RuntimeError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=str(exc) + ) from exc now = get_utc_now() with get_db() as conn: conn.execute( @@ -721,9 +842,9 @@ async def sync_idea_to_opengist(idea_id: str, current_user: User = Depends(get_c cats = [r["name"] for r in conn.execute("SELECT c.name FROM categories c JOIN idea_categories ic ON c.id = ic.category_id WHERE ic.idea_id = ?", (idea_id,)).fetchall()] tags = [r["name"] for r in conn.execute("SELECT t.name FROM tags t JOIN idea_tags it ON t.id = it.tag_id WHERE it.idea_id = ?", (idea_id,)).fetchall()] - runs = [dict(r) for r in conn.execute("SELECT * FROM processor_runs WHERE idea_id = ?", (idea_id,)).fetchall()] + runs = [dict(r) for r in conn.execute("SELECT * FROM processor_runs WHERE idea_id = ? ORDER BY started_at ASC", (idea_id,)).fetchall()] - # Build research docs + # Build research docs (chronologically latest run content takes precedence) research_docs = {} for r in runs: stage = r.get("stage") @@ -734,7 +855,9 @@ async def sync_idea_to_opengist(idea_id: str, current_user: User = Depends(get_c out_data = {} content = out_data.get("content") if content: - if stage == "PRIOR_ART": + if stage == "IMAGE_CONTEXT": + research_docs["image-context.md"] = content + elif stage == "PRIOR_ART": research_docs["prior-art.md"] = content elif stage == "RESEARCH": research_docs["analysis.md"] = content @@ -772,18 +895,19 @@ async def sync_idea_to_opengist(idea_id: str, current_user: User = Depends(get_c } @router.post("/{idea_id}/sync-gitea") -async def sync_idea_to_gitea(idea_id: str, current_user: User = Depends(get_current_user)): - """Manually triggers full Gitea Project Repository sync for an idea under 'thinkstorm' org.""" +async def sync_idea_to_gitea(idea_id: str, current_user: User = Depends(require_authenticated)): + """Publishes or re-syncs a claimed idea and grants its claimant write access.""" with get_db() as conn: idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone() if not idea: raise HTTPException(status_code=404, detail="Idea not found.") + claimant_username = _resolve_gitea_claimant(conn, idea, current_user) cats = [r["name"] for r in conn.execute("SELECT c.name FROM categories c JOIN idea_categories ic ON c.id = ic.category_id WHERE ic.idea_id = ?", (idea_id,)).fetchall()] tags = [r["name"] for r in conn.execute("SELECT t.name FROM tags t JOIN idea_tags it ON t.id = it.tag_id WHERE it.idea_id = ?", (idea_id,)).fetchall()] - runs = [dict(r) for r in conn.execute("SELECT * FROM processor_runs WHERE idea_id = ?", (idea_id,)).fetchall()] + runs = [dict(r) for r in conn.execute("SELECT * FROM processor_runs WHERE idea_id = ? ORDER BY started_at ASC", (idea_id,)).fetchall()] - # Build research docs + # Build research docs (chronologically latest run content takes precedence) research_docs = {} for r in runs: stage = r.get("stage") @@ -794,7 +918,9 @@ async def sync_idea_to_gitea(idea_id: str, current_user: User = Depends(get_curr out_data = {} content = out_data.get("content") if content: - if stage == "PRIOR_ART": + if stage == "IMAGE_CONTEXT": + research_docs["image-context.md"] = content + elif stage == "PRIOR_ART": research_docs["prior-art.md"] = content elif stage == "RESEARCH": research_docs["analysis.md"] = content @@ -813,19 +939,30 @@ async def sync_idea_to_gitea(idea_id: str, current_user: User = Depends(get_curr track_slug = tr["name"].lower().replace(" ", "-") outputs[f"{track_slug}/{out['name']}"] = out["content"] - res = await gitea_svc.persist_idea_dossier_repo( - idea_id=idea["id"], - title=idea["title"] or "Untitled Idea", - summary=idea["summary"] or "", - original_text=idea["original_text"] or "", - categories=cats, - tags=tags, - lifecycle_state=idea["lifecycle_state"], - research_docs=research_docs, - outputs=outputs, - provenance_runs=runs, - existing_repo_url=idea.get("gitea_repo_url") if isinstance(idea, dict) else (idea["gitea_repo_url"] if "gitea_repo_url" in idea.keys() else None) - ) + try: + res = await gitea_svc.persist_idea_dossier_repo( + idea_id=idea["id"], + title=idea["title"] or "Untitled Idea", + summary=idea["summary"] or "", + original_text=idea["original_text"] or "", + categories=cats, + tags=tags, + lifecycle_state=idea["lifecycle_state"], + research_docs=research_docs, + outputs=outputs, + provenance_runs=runs, + existing_repo_url=( + idea["gitea_repo_url"] + if "gitea_repo_url" in idea.keys() else None + ), + publish_remote=True, + collaborator_username=claimant_username + ) + except RuntimeError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=str(exc) + ) from exc now = get_utc_now() with get_db() as conn: @@ -837,16 +974,38 @@ async def sync_idea_to_gitea(idea_id: str, current_user: User = Depends(get_curr """, (res["repo_name"], res["repo_url"], now, idea_id) ) - conn.execute( + existing_resource = conn.execute( """ - INSERT INTO external_resources (idea_id, resource_type, url, metadata, created_at) - VALUES (?, 'GITEA_DOSSIER', ?, ?, ?) + SELECT id FROM external_resources + WHERE idea_id = ? AND resource_type = 'GITEA_DOSSIER' + ORDER BY id ASC LIMIT 1 """, - (idea_id, res["repo_url"], json.dumps(res), now) - ) + (idea_id,) + ).fetchone() + if existing_resource: + conn.execute( + """ + UPDATE external_resources + SET url = ?, metadata = ? + WHERE id = ? + """, + (res["repo_url"], json.dumps(res), existing_resource["id"]) + ) + else: + conn.execute( + """ + INSERT INTO external_resources + (idea_id, resource_type, url, metadata, created_at) + VALUES (?, 'GITEA_DOSSIER', ?, ?, ?) + """, + (idea_id, res["repo_url"], json.dumps(res), now) + ) return { - "message": f"Dossier successfully published to Gitea under '{res['repo_name']}'.", + "message": ( + f"Dossier published to '{res['repo_name']}' and write access granted " + f"to '{claimant_username}'." + ), "gitea_repo_name": res["repo_name"], "gitea_repo_url": res["repo_url"], "clone_url": res["clone_url"], @@ -859,7 +1018,7 @@ async def reprocess_idea( bypass_duplicate_check: bool = True, current_user: User = Depends(get_current_user) ): - """Triggers end-to-end reprocessing of an idea (Prior Art, Deep Research, Feasibility, and Gitea Dossier).""" + """Triggers research reprocessing without automatically publishing to Gitea.""" with get_db() as conn: idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone() if not idea: @@ -903,4 +1062,3 @@ async def get_idea_image(idea_id: str): media_type=mime, filename=meta.get("original_filename", f"{idea_id}_reference_image") ) - diff --git a/thinkstorm/auth.py b/thinkstorm/auth.py index 2f9c9c1..b974982 100644 --- a/thinkstorm/auth.py +++ b/thinkstorm/auth.py @@ -72,10 +72,20 @@ def get_or_create_gitea_user(gitea_user_data: Dict[str, Any]) -> User: with get_db() as conn: row = conn.execute("SELECT id, username, password_hash, role, created_at FROM users WHERE username = ? OR gitea_id = ?", (username, gitea_id)).fetchone() if row: - # Update role if promoted - if is_gitea_admin and row["role"] != "ADMIN": - conn.execute("UPDATE users SET role = 'ADMIN' WHERE id = ?", (row["id"],)) - return User(id=row["id"], username=row["username"], password_hash=row["password_hash"], role=role, created_at=row["created_at"]) + # Record the verified Gitea identity when an existing local account links + # through OAuth, and promote the role when appropriate. + resolved_role = "ADMIN" if is_gitea_admin else row["role"] + conn.execute( + "UPDATE users SET gitea_id = ?, role = ? WHERE id = ?", + (gitea_id, resolved_role, row["id"]) + ) + return User( + id=row["id"], + username=row["username"], + password_hash=row["password_hash"], + role=UserRole(resolved_role), + created_at=row["created_at"] + ) # Insert new user dummy_pass = hash_password(secrets.token_hex(16)) diff --git a/thinkstorm/processors/pipeline.py b/thinkstorm/processors/pipeline.py index a0d3410..a02330e 100644 --- a/thinkstorm/processors/pipeline.py +++ b/thinkstorm/processors/pipeline.py @@ -631,8 +631,8 @@ async def execute_intake_pipeline(idea_id: str, bypass_duplicate_check: bool = F with get_db() as conn: runs = [dict(r) for r in conn.execute("SELECT * FROM processor_runs WHERE idea_id = ?", (idea_id,)).fetchall()] - # 1. Primary: Persist as a dedicated Gitea Project Repository under 'thinkstorm' org - gitea_res = await gitea_svc.persist_idea_dossier_repo( + # Persist the dossier locally. Remote Gitea publication is claimant-triggered. + await gitea_svc.persist_idea_dossier_repo( idea_id=idea_id, title=title, summary=summary, @@ -643,10 +643,11 @@ async def execute_intake_pipeline(idea_id: str, bypass_duplicate_check: bool = F research_docs=research_docs, outputs={}, provenance_runs=runs, - submission_image=submission_image + submission_image=submission_image, + publish_remote=False ) - # 2. Secondary: Persist local durable files & OpenGist + # Publish the research dossier to OpenGist as before. gist_res = await opengist_svc.persist_idea_artifact( idea_id=idea_id, title=title, @@ -661,7 +662,7 @@ async def execute_intake_pipeline(idea_id: str, bypass_duplicate_check: bool = F submission_image=submission_image ) - # Finalize Idea to AVAILABLE and record repository links + # Finalize the idea without creating or predicting a Gitea repository. with get_db() as conn: conn.execute( """ @@ -669,22 +670,12 @@ async def execute_intake_pipeline(idea_id: str, bypass_duplicate_check: bool = F SET lifecycle_state = 'AVAILABLE', processing_state = 'IDLE', enrichment_level = 5, - gitea_repo_name = ?, - gitea_repo_url = ?, opengist_id = ?, opengist_url = ?, updated_at = ? WHERE id = ? """, - (gitea_res["repo_name"], gitea_res["repo_url"], gist_res["opengist_id"], gist_res["opengist_url"], get_utc_now(), idea_id) - ) - # Store in external_resources - conn.execute( - """ - INSERT INTO external_resources (idea_id, resource_type, url, metadata, created_at) - VALUES (?, 'GITEA_DOSSIER', ?, ?, ?) - """, - (idea_id, gitea_res["repo_url"], json.dumps(gitea_res), get_utc_now()) + (gist_res["opengist_id"], gist_res["opengist_url"], get_utc_now(), idea_id) ) except Exception as e: @@ -738,11 +729,14 @@ async def execute_work_track_workflow(work_track_id: str, model_override: Option start_time = get_utc_now() generated_outputs: Dict[str, str] = {} + idea_title = str(idea["title"] or idea["id"]) + idea_summary = str(idea["summary"] or "") + if work_type == "ARTICLE" or work_type == "BLOG_ENTRY": prompt_info = get_prompt_version("article-generator", is_admin=True) user_prompt = ( prompt_info["user_prompt_template"] - .replace("{{title}}", idea["title"]) + .replace("{{title}}", idea_title) .replace("{{track_name}}", track_name) .replace("{{research_context}}", combined_research) ) @@ -761,8 +755,8 @@ async def execute_work_track_workflow(work_track_id: str, model_override: Option prompt_info = get_prompt_version("coding-spec-generator", is_admin=True) user_prompt = ( prompt_info["user_prompt_template"] - .replace("{{title}}", idea["title"]) - .replace("{{summary}}", idea["summary"]) + .replace("{{title}}", idea_title) + .replace("{{summary}}", idea_summary) .replace("{{feasibility_context}}", combined_research) ) llm_resp = await omniroute_svc.chat_completion( @@ -781,9 +775,9 @@ async def execute_work_track_workflow(work_track_id: str, model_override: Option prompt_info = get_prompt_version("youtube-video-generator", is_admin=True) user_prompt = ( prompt_info["user_prompt_template"] - .replace("{{title}}", idea["title"]) + .replace("{{title}}", idea_title) .replace("{{track_name}}", track_name) - .replace("{{summary}}", idea["summary"]) + .replace("{{summary}}", idea_summary) .replace("{{research_context}}", combined_research) ) llm_resp = await omniroute_svc.chat_completion( @@ -821,9 +815,14 @@ async def execute_work_track_workflow(work_track_id: str, model_override: Option ) conn.execute("UPDATE work_tracks SET state = 'COMPLETED', completed_at = ? WHERE id = ?", (end_time, work_track_id)) - # Sync deliverables to Gitea Repository & OpenGist + # Keep Work Track deliverables local until the claimant publishes the dossier. try: - await gitea_svc.persist_work_track_outputs(idea_id, track_name, generated_outputs) + await gitea_svc.persist_work_track_outputs( + idea_id, + track_name, + generated_outputs, + publish_remote=False + ) except Exception as e: print(f"[Gitea Sync Notice] {e}") diff --git a/thinkstorm/services/gitea.py b/thinkstorm/services/gitea.py index 5a1ef45..d23a620 100644 --- a/thinkstorm/services/gitea.py +++ b/thinkstorm/services/gitea.py @@ -50,7 +50,7 @@ class GiteaAdapter(BaseServiceAdapter): def _get_headers(self, token: Optional[str] = None) -> Dict[str, str]: t = token or self.get_effective_token() headers = { - "User-Agent": "ThinkStorm-Orchestrator/0.1", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", "Content-Type": "application/json" } if t: @@ -62,13 +62,14 @@ class GiteaAdapter(BaseServiceAdapter): loop = asyncio.get_running_loop() try: url = f"{self.endpoint}/api/v1/version" + headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"} def fetch(): - return requests.get(url, headers={"User-Agent": "ThinkStorm-Orchestrator/0.1"}, timeout=6.0) + return requests.get(url, headers=headers, timeout=15.0) resp = await loop.run_in_executor(None, fetch) + elapsed = int((time.time() - start) * 1000) if resp.status_code == 200: data = resp.json() version = data.get("version", "unknown") - elapsed = int((time.time() - start) * 1000) return ServiceHealth( service_id=self.service_id, healthy=True, @@ -77,15 +78,13 @@ class GiteaAdapter(BaseServiceAdapter): response_time_ms=elapsed, extra={"version": version} ) - else: - elapsed = int((time.time() - start) * 1000) - return ServiceHealth( - service_id=self.service_id, - healthy=False, - endpoint=self.endpoint, - message=f"Gitea HTTP error: {resp.status_code}", - response_time_ms=elapsed - ) + return ServiceHealth( + service_id=self.service_id, + healthy=False, + endpoint=self.endpoint, + message=f"Gitea returned HTTP {resp.status_code}", + response_time_ms=elapsed + ) except Exception as e: elapsed = int((time.time() - start) * 1000) return ServiceHealth( @@ -100,7 +99,7 @@ class GiteaAdapter(BaseServiceAdapter): """Ensures the 'thinkstorm' organization exists.""" headers = self._get_headers(token) try: - r = session.get(f"{self.endpoint}/api/v1/orgs/{self.org_name}", headers=headers, timeout=8) + r = session.get(f"{self.endpoint}/api/v1/orgs/{self.org_name}", headers=headers, timeout=60) if r.status_code == 200: return if r.status_code == 404: @@ -110,7 +109,7 @@ class GiteaAdapter(BaseServiceAdapter): "description": "Canonical dossiers, research, and project incubations generated by ThinkStorm", "visibility": "public" } - session.post(f"{self.endpoint}/api/v1/orgs", json=payload, headers=headers, timeout=8) + session.post(f"{self.endpoint}/api/v1/orgs", json=payload, headers=headers, timeout=60) except Exception as e: print(f"[Gitea] Org ensure notice: {e}") @@ -118,7 +117,7 @@ class GiteaAdapter(BaseServiceAdapter): """Ensures the repository exists under the organization.""" headers = self._get_headers(token) try: - r = session.get(f"{self.endpoint}/api/v1/repos/{self.org_name}/{repo_name}", headers=headers, timeout=8) + r = session.get(f"{self.endpoint}/api/v1/repos/{self.org_name}/{repo_name}", headers=headers, timeout=60) if r.status_code == 200: return True if r.status_code == 404: @@ -129,13 +128,38 @@ class GiteaAdapter(BaseServiceAdapter): "auto_init": True, "default_branch": "main" } - cr = session.post(f"{self.endpoint}/api/v1/orgs/{self.org_name}/repos", json=payload, headers=headers, timeout=10) + cr = session.post(f"{self.endpoint}/api/v1/orgs/{self.org_name}/repos", json=payload, headers=headers, timeout=60) return cr.status_code in (200, 201) except Exception as e: print(f"[Gitea] Repo ensure error for {repo_name}: {e}") return False return False + def _grant_repo_write_access( + self, + session: requests.Session, + token: str, + repo_name: str, + username: str + ) -> None: + """Idempotently grants the verified claimant write access to a repository.""" + safe_username = requests.utils.quote(username, safe="") + url = ( + f"{self.endpoint}/api/v1/repos/{self.org_name}/{repo_name}" + f"/collaborators/{safe_username}" + ) + response = session.put( + url, + json={"permission": "write"}, + headers=self._get_headers(token), + timeout=60 + ) + if response.status_code not in (200, 201, 204): + raise RuntimeError( + f"Could not grant Gitea write access to '{username}' " + f"(HTTP {response.status_code})." + ) + def _sync_files_via_api(self, token: str, repo_slug: str, files_dict: Dict[str, str]) -> None: """Commits or updates multiple files directly into the Gitea repository via Contents API.""" if not files_dict or not token: @@ -147,7 +171,7 @@ class GiteaAdapter(BaseServiceAdapter): try: # 1. Check if file already exists in repo to get current SHA get_url = f"{self.endpoint}/api/v1/repos/{self.org_name}/{repo_slug}/contents/{path}" - r = s.get(get_url, headers=headers, timeout=8) + r = s.get(get_url, headers=headers, timeout=60) sha = None if r.status_code == 200: sha = r.json().get("sha") @@ -161,11 +185,11 @@ class GiteaAdapter(BaseServiceAdapter): if sha: payload["sha"] = sha - put_res = s.put(get_url, json=payload, headers=headers, timeout=10) + put_res = s.put(get_url, json=payload, headers=headers, timeout=60) if put_res.status_code not in (200, 201): print(f"[Gitea] Update notice for {path}: {put_res.status_code} {put_res.text[:100]}") else: - post_res = s.post(get_url, json=payload, headers=headers, timeout=10) + post_res = s.post(get_url, json=payload, headers=headers, timeout=60) if post_res.status_code not in (200, 201): print(f"[Gitea] Create notice for {path}: {post_res.status_code} {post_res.text[:100]}") except Exception as e: @@ -184,9 +208,11 @@ class GiteaAdapter(BaseServiceAdapter): outputs: Dict[str, str], provenance_runs: List[Dict[str, Any]], existing_repo_url: Optional[str] = None, - submission_image: Optional[Dict[str, Any]] = None + submission_image: Optional[Dict[str, Any]] = None, + publish_remote: bool = True, + collaborator_username: Optional[str] = None ) -> Dict[str, Any]: - """Creates a dedicated Gitea Project repository and synchronizes all files into it.""" + """Persists a dossier locally and optionally publishes it to Gitea.""" # 1. Local disk directory idea_dir = ARTIFACTS_DIR / idea_id idea_dir.mkdir(parents=True, exist_ok=True) @@ -284,43 +310,43 @@ class GiteaAdapter(BaseServiceAdapter): clone_url = f"{self.endpoint}/{self.org_name}/{repo_name}.git" ssh_url = f"ssh://git@git.labyricorn.com:22/{self.org_name}/{repo_name}.git" - token = self.get_effective_token() - if token: + token = self.get_effective_token() if publish_remote else "" + if publish_remote: + if not token: + raise RuntimeError("Gitea publishing is not configured with a service token.") loop = asyncio.get_running_loop() def sync_gitea(): - try: - with requests.Session() as s: - # 1. Ensure org and repo exist - self._ensure_org(s, token) - self._ensure_repo(s, token, repo_name, f"[{idea_id}] {title}") - # 2. Push full file tree to Gitea - self._sync_files_via_api(token, repo_name, files_to_sync) - return f"{self.endpoint}/{self.org_name}/{repo_name}" - except Exception as e: - print(f"[Gitea] Sync error: {e}") - return repo_url + with requests.Session() as s: + self._ensure_org(s, token) + if not self._ensure_repo(s, token, repo_name, f"[{idea_id}] {title}"): + raise RuntimeError(f"Could not create or access Gitea repository '{repo_name}'.") + if collaborator_username: + self._grant_repo_write_access( + s, token, repo_name, collaborator_username + ) + self._sync_files_via_api(token, repo_name, files_to_sync) + return f"{self.endpoint}/{self.org_name}/{repo_name}" - try: - actual_url = await loop.run_in_executor(None, sync_gitea) - repo_url = actual_url or repo_url - except Exception as e: - print(f"[Gitea] Async sync exception: {e}") + actual_url = await loop.run_in_executor(None, sync_gitea) + repo_url = actual_url or repo_url return { "repo_name": f"{self.org_name}/{repo_name}", "repo_url": repo_url, "clone_url": clone_url, "ssh_url": ssh_url, - "local_path": str(idea_dir) + "local_path": str(idea_dir), + "published": publish_remote } async def persist_work_track_outputs( self, idea_id: str, track_name: str, - outputs: Dict[str, str] + outputs: Dict[str, str], + publish_remote: bool = True ) -> Dict[str, Any]: - """Persists Work Track deliverables to local dossier and commits to Gitea project.""" + """Persists Work Track deliverables locally and optionally pushes them.""" idea_dir = ARTIFACTS_DIR / idea_id out_subdir = idea_dir / "outputs" / track_name.lower().replace(" ", "-") out_subdir.mkdir(parents=True, exist_ok=True) @@ -335,7 +361,7 @@ class GiteaAdapter(BaseServiceAdapter): repo_name = f"ts-{clean_slug.replace('ts-', '')}" repo_url = f"{self.endpoint}/{self.org_name}/{repo_name}" - token = self.get_effective_token() + token = self.get_effective_token() if publish_remote else "" if token and files_to_sync: loop = asyncio.get_running_loop() try: @@ -348,31 +374,40 @@ class GiteaAdapter(BaseServiceAdapter): print(f"[Gitea] Work track sync notice: {e}") return { - "status": "SUCCESS", + "status": "SUCCESS" if publish_remote else "LOCAL_ONLY", "path": str(out_subdir), - "repo_url": repo_url + "repo_url": repo_url if publish_remote else "" } - async def graduate_project(self, idea_id: str, title: str, summary: str, repo_name: str = "") -> Dict[str, Any]: + async def graduate_project( + self, + idea_id: str, + title: str, + summary: str, + repo_name: str = "", + collaborator_username: Optional[str] = None + ) -> Dict[str, Any]: """Graduates an incubated Coding Project work track to a dedicated repository under 'thinkstorm'.""" clean_title = re.sub(r'[^a-zA-Z0-9_-]', '-', title.lower()).strip('-')[:30] or "project" slug = repo_name or f"ts-{idea_id.lower()}-{clean_title}" repo_url = f"{self.endpoint}/{self.org_name}/{slug}" token = self.get_effective_token() - if token: - loop = asyncio.get_running_loop() - def create_remote(): - try: - with requests.Session() as s: - self._ensure_org(s, token) - self._ensure_repo(s, token, slug, f"[{idea_id}] {title}") - return f"{self.endpoint}/{self.org_name}/{slug}" - except Exception as e: - print(f"[Gitea] Project graduation notice: {e}") - return repo_url - actual_url = await loop.run_in_executor(None, create_remote) - repo_url = actual_url or repo_url + if not token: + raise RuntimeError("Gitea publishing is not configured with a service token.") + loop = asyncio.get_running_loop() + def create_remote(): + with requests.Session() as s: + self._ensure_org(s, token) + if not self._ensure_repo(s, token, slug, f"[{idea_id}] {title}"): + raise RuntimeError(f"Could not create or access Gitea repository '{slug}'.") + if collaborator_username: + self._grant_repo_write_access( + s, token, slug, collaborator_username + ) + return f"{self.endpoint}/{self.org_name}/{slug}" + actual_url = await loop.run_in_executor(None, create_remote) + repo_url = actual_url or repo_url return { "repo_name": f"{self.org_name}/{slug}", diff --git a/thinkstorm/static/js/app.js b/thinkstorm/static/js/app.js index 529f987..6261d61 100644 --- a/thinkstorm/static/js/app.js +++ b/thinkstorm/static/js/app.js @@ -471,6 +471,7 @@ async function syncIdeaToOpenGist(ideaId) { async function syncIdeaToGitea(ideaId) { const btn = document.getElementById('sync-gitea-btn'); + const originalLabel = btn ? btn.innerText : ''; if (btn) btn.innerText = 'Publishing to Gitea...'; sessionStorage.setItem(`thinkstorm_active_tab_${window.location.pathname}`, 'tab-artifacts'); history.replaceState(null, null, '#artifacts'); @@ -482,7 +483,7 @@ async function syncIdeaToGitea(ideaId) { setTimeout(() => window.location.reload(), 800); } catch (err) { showToast(err.message, 'danger'); - if (btn) btn.innerText = '🔄 Publish / Re-sync to Gitea'; + if (btn) btn.innerText = originalLabel; } } @@ -535,32 +536,50 @@ function initMarkdownViews(root = document) { if (container.dataset.initialized === 'true') return; container.dataset.initialized = 'true'; - const formattedBox = container.querySelector('.markdown-formatted'); - const codeBox = container.querySelector('.markdown-code'); - const rawSourceEl = container.querySelector('.raw-markdown-source'); - - let rawText = ''; - if (rawSourceEl) { - rawText = rawSourceEl.value || rawSourceEl.textContent || ''; - } else if (codeBox && codeBox.querySelector('code')) { - rawText = codeBox.querySelector('code').textContent || ''; - } else if (container.dataset.content) { - rawText = container.dataset.content; - } + // Check if this container has multi-version panes + const versionPanes = container.querySelectorAll('.research-version-pane'); + if (versionPanes.length > 0) { + versionPanes.forEach(pane => { + const formattedBox = pane.querySelector('.markdown-formatted'); + const codeBox = pane.querySelector('.markdown-code'); + const rawSourceEl = pane.querySelector('.raw-markdown-source'); + const rawText = rawSourceEl ? (rawSourceEl.value || rawSourceEl.textContent || '') : ''; - // Render formatted markdown HTML - if (formattedBox && rawText) { - formattedBox.innerHTML = renderMarkdownContent(rawText); - } + if (formattedBox && rawText) { + formattedBox.innerHTML = renderMarkdownContent(rawText); + } + if (codeBox && (!codeBox.querySelector('code') || !codeBox.querySelector('code').textContent)) { + codeBox.innerHTML = `
${escapeHtml(rawText)}
`; + } + }); + } else { + const formattedBox = container.querySelector('.markdown-formatted'); + const codeBox = container.querySelector('.markdown-code'); + const rawSourceEl = container.querySelector('.raw-markdown-source'); + + let rawText = ''; + if (rawSourceEl) { + rawText = rawSourceEl.value || rawSourceEl.textContent || ''; + } else if (codeBox && codeBox.querySelector('code')) { + rawText = codeBox.querySelector('code').textContent || ''; + } else if (container.dataset.content) { + rawText = container.dataset.content; + } - // Ensure codeBox has raw text - if (codeBox && (!codeBox.querySelector('code') || !codeBox.querySelector('code').textContent)) { - codeBox.innerHTML = `
${escapeHtml(rawText)}
`; - } + // Render formatted markdown HTML + if (formattedBox && rawText) { + formattedBox.innerHTML = renderMarkdownContent(rawText); + } - // Default to Formatted view - if (formattedBox) formattedBox.style.display = 'block'; - if (codeBox) codeBox.style.display = 'none'; + // Ensure codeBox has raw text + if (codeBox && (!codeBox.querySelector('code') || !codeBox.querySelector('code').textContent)) { + codeBox.innerHTML = `
${escapeHtml(rawText)}
`; + } + + // Default to Formatted view + if (formattedBox) formattedBox.style.display = 'block'; + if (codeBox) codeBox.style.display = 'none'; + } // Setup toggle buttons const toggleBtns = container.querySelectorAll('.btn-toggle-view'); @@ -587,10 +606,7 @@ function initMarkdownViews(root = document) { } function setMarkdownContainerView(container, mode) { - const formattedBox = container.querySelector('.markdown-formatted'); - const codeBox = container.querySelector('.markdown-code'); const toggleBtns = container.querySelectorAll('.btn-toggle-view'); - toggleBtns.forEach(b => { if (b.getAttribute('data-view') === mode) { b.classList.add('active'); @@ -599,21 +615,82 @@ function setMarkdownContainerView(container, mode) { } }); - if (mode === 'code') { - if (formattedBox) formattedBox.style.display = 'none'; - if (codeBox) codeBox.style.display = 'block'; + const versionPanes = container.querySelectorAll('.research-version-pane'); + if (versionPanes.length > 0) { + versionPanes.forEach(pane => { + const formattedBox = pane.querySelector('.markdown-formatted'); + const codeBox = pane.querySelector('.markdown-code'); + if (mode === 'code') { + if (formattedBox) formattedBox.style.display = 'none'; + if (codeBox) codeBox.style.display = 'block'; + } else { + if (formattedBox) formattedBox.style.display = 'block'; + if (codeBox) codeBox.style.display = 'none'; + } + }); } else { - // Formatted by default - if (formattedBox) formattedBox.style.display = 'block'; - if (codeBox) codeBox.style.display = 'none'; + const formattedBox = container.querySelector('.markdown-formatted'); + const codeBox = container.querySelector('.markdown-code'); + if (mode === 'code') { + if (formattedBox) formattedBox.style.display = 'none'; + if (codeBox) codeBox.style.display = 'block'; + } else { + if (formattedBox) formattedBox.style.display = 'block'; + if (codeBox) codeBox.style.display = 'none'; + } + } +} + +function switchResearchVersion(selectEl, stage) { + const selectedVersion = String(selectEl.value); + const container = selectEl.closest('.tab-panel') || document.getElementById(`tab-${stage.toLowerCase().replace('_', '-')}`); + if (!container) return; + + // Toggle panes for this stage + const panes = container.querySelectorAll(`.research-version-pane[data-stage="${stage}"]`); + let activePane = null; + panes.forEach(pane => { + if (String(pane.dataset.version) === selectedVersion) { + pane.style.display = 'block'; + activePane = pane; + } else { + pane.style.display = 'none'; + } + }); + + // Update header meta badges + if (activePane) { + const metaContainer = container.querySelector('.markdown-view-meta'); + if (metaContainer) { + const modelBadge = metaContainer.querySelector('.research-meta-model'); + if (modelBadge && activePane.dataset.model) { + modelBadge.innerText = `🤖 ${activePane.dataset.model}`; + } + const tokenBadge = metaContainer.querySelector('.research-meta-tokens'); + if (tokenBadge && activePane.dataset.tokens) { + tokenBadge.innerText = `⚡ ${activePane.dataset.tokens} tokens`; + } + const timeBadge = metaContainer.querySelector('.research-meta-time'); + if (timeBadge && activePane.dataset.time) { + timeBadge.innerText = `🕒 ${activePane.dataset.time}`; + } + } } } async function copyMarkdownFromContainer(btn) { const container = btn.closest('.markdown-view-container'); if (!container) return; - const rawSourceEl = container.querySelector('.raw-markdown-source'); - const codeEl = container.querySelector('.markdown-code code'); + + // Find visible pane if multi-version + let targetRoot = container; + const visiblePane = container.querySelector('.research-version-pane:not([style*="display:none"]):not([style*="display: none"])'); + if (visiblePane) { + targetRoot = visiblePane; + } + + const rawSourceEl = targetRoot.querySelector('.raw-markdown-source'); + const codeEl = targetRoot.querySelector('.markdown-code code'); const text = rawSourceEl ? (rawSourceEl.value || rawSourceEl.textContent) : (codeEl ? codeEl.textContent : ''); if (!text) { @@ -660,4 +737,5 @@ function escapeHtml(text) { window.renderMarkdownContent = renderMarkdownContent; window.initMarkdownViews = initMarkdownViews; window.setMarkdownContainerView = setMarkdownContainerView; +window.switchResearchVersion = switchResearchVersion; window.copyMarkdownFromContainer = copyMarkdownFromContainer; diff --git a/thinkstorm/templates/base.html b/thinkstorm/templates/base.html index f8213d5..003c0cb 100644 --- a/thinkstorm/templates/base.html +++ b/thinkstorm/templates/base.html @@ -60,6 +60,6 @@ - + diff --git a/thinkstorm/templates/idea_detail.html b/thinkstorm/templates/idea_detail.html index 199ce82..57502ed 100644 --- a/thinkstorm/templates/idea_detail.html +++ b/thinkstorm/templates/idea_detail.html @@ -278,17 +278,37 @@ {% if idea.submission_image and idea.submission_image.present %}
- {% set image_ctx_run = idea.provenance | selectattr("stage", "equalto", "IMAGE_CONTEXT") | list %} - {% if image_ctx_run and image_ctx_run|length > 0 and image_ctx_run[0].output_data.content %} + {% set stage_data = idea.research_stages.IMAGE_CONTEXT if idea.research_stages else None %} + {% set failed_image_run = idea.provenance | selectattr("stage", "equalto", "IMAGE_CONTEXT") | selectattr("status", "equalto", "FAILED") | list %} + {% if stage_data and stage_data.total_versions > 0 %}
-
-
+
+
Stage: IMAGE_CONTEXT AI Visual Interpretation - {% if image_ctx_run[0].resolved_model %} - 🤖 {{ image_ctx_run[0].resolved_model }} + + {% if stage_data.total_versions > 1 %} +
+ + +
+ {% else %} + v1 (Latest) {% endif %} + + 🤖 {{ stage_data.latest.model }} + {% if stage_data.latest.tokens.total %} + ⚡ {{ stage_data.latest.tokens.total }} tokens + {% endif %} + 🕒 {{ stage_data.latest.started_at[:19].replace('T', ' ') }} UTC
+
+
-
- - + {% for v in stage_data.versions %} +
+
+ + +
+ {% endfor %}
- {% elif image_ctx_run and image_ctx_run|length > 0 and image_ctx_run[0].status == 'FAILED' %} + {% elif failed_image_run and failed_image_run|length > 0 %}
⚠️

Vision Processing Notice

The reference image was safely preserved, but automated vision analysis was unavailable or encountered an error: - {{ image_ctx_run[0].error_message or "Model refused or failed image processing." }}. + {{ failed_image_run[0].error_message or "Model refused or failed image processing." }}. The idea continues with text-only research synthesis.

@@ -335,16 +360,35 @@
- {% set prior_art_run = idea.provenance | selectattr("stage", "equalto", "PRIOR_ART") | list %} - {% if prior_art_run and prior_art_run|length > 0 and prior_art_run[0].output_data.content %} + {% set stage_data = idea.research_stages.PRIOR_ART if idea.research_stages else None %} + {% if stage_data and stage_data.total_versions > 0 %}
-
-
+
+
Stage: PRIOR_ART - {% if prior_art_run[0].resolved_model %} - 🤖 {{ prior_art_run[0].resolved_model }} + + {% if stage_data.total_versions > 1 %} +
+ + +
+ {% else %} + v1 (Latest) {% endif %} + + 🤖 {{ stage_data.latest.model }} + {% if stage_data.latest.tokens.total %} + ⚡ {{ stage_data.latest.tokens.total }} tokens + {% endif %} + 🕒 {{ stage_data.latest.started_at[:19].replace('T', ' ') }} UTC
+
+
-
- - + {% for v in stage_data.versions %} +
+
+ + +
+ {% endfor %}
{% else %} @@ -383,16 +432,35 @@
- {% set research_run = idea.provenance | selectattr("stage", "equalto", "RESEARCH") | list %} - {% if research_run and research_run|length > 0 and research_run[0].output_data.content %} + {% set stage_data = idea.research_stages.RESEARCH if idea.research_stages else None %} + {% if stage_data and stage_data.total_versions > 0 %}
-
-
+
+
Stage: RESEARCH - {% if research_run[0].resolved_model %} - 🤖 {{ research_run[0].resolved_model }} + + {% if stage_data.total_versions > 1 %} +
+ + +
+ {% else %} + v1 (Latest) {% endif %} + + 🤖 {{ stage_data.latest.model }} + {% if stage_data.latest.tokens.total %} + ⚡ {{ stage_data.latest.tokens.total }} tokens + {% endif %} + 🕒 {{ stage_data.latest.started_at[:19].replace('T', ' ') }} UTC
+
+
-
- - + {% for v in stage_data.versions %} +
+
+ + +
+ {% endfor %}
{% else %} @@ -431,16 +504,35 @@
- {% set feas_run = idea.provenance | selectattr("stage", "equalto", "FEASIBILITY") | list %} - {% if feas_run and feas_run|length > 0 and feas_run[0].output_data.content %} + {% set stage_data = idea.research_stages.FEASIBILITY if idea.research_stages else None %} + {% if stage_data and stage_data.total_versions > 0 %}
-
-
+
+
Stage: FEASIBILITY - {% if feas_run[0].resolved_model %} - 🤖 {{ feas_run[0].resolved_model }} + + {% if stage_data.total_versions > 1 %} +
+ + +
+ {% else %} + v1 (Latest) {% endif %} + + 🤖 {{ stage_data.latest.model }} + {% if stage_data.latest.tokens.total %} + ⚡ {{ stage_data.latest.tokens.total }} tokens + {% endif %} + 🕒 {{ stage_data.latest.started_at[:19].replace('T', ' ') }} UTC
+
+
-
- - + {% for v in stage_data.versions %} +
+
+ + +
+ {% endfor %}
{% else %} @@ -537,7 +634,7 @@ - {% if tr.work_type_id == 'CODING_PROJECT' and tr.state == 'COMPLETED' %} + {% if tr.work_type_id == 'CODING_PROJECT' and tr.state == 'COMPLETED' and idea.gitea_publish_allowed %} {% endif %} {% endif %} @@ -681,7 +778,7 @@

Canonical Dossier & Gitea Project

-

Every idea is maintained as a full Git repository under the thinkstorm organization on Gitea.

+

Ideas remain local until a Gitea-linked claimant deliberately publishes them under the thinkstorm organization.

{% if idea.gitea_repo_url %} @@ -701,24 +798,31 @@ org: thinkstorm
- Repo: {{ idea.gitea_repo_name or "thinkstorm/ts-" ~ idea.id|lower|replace('ts-', '') }} + Repo: + {% if idea.gitea_repo_url %} + {{ idea.gitea_repo_name }} + {% else %} + Not published + {% endif %}
+ {% if idea.gitea_repo_url %}
- Clone: git clone https://git.labyricorn.com/{{ idea.gitea_repo_name or "thinkstorm/ts-" ~ idea.id|lower|replace('ts-', '') }}.git + Clone: git clone https://git.labyricorn.com/{{ idea.gitea_repo_name }}.git
+ {% endif %}
+ {% if idea.gitea_publish_allowed %} + {% elif idea.gitea_publish_reason %} + {{ idea.gitea_publish_reason }} + {% endif %} {% if idea.gitea_repo_url %} Open in Gitea ↗ - {% else %} - - Open in Gitea ↗ - {% endif %}