auto-git:

[change] concept-maker_gui.py
This commit is contained in:
Victor Giers
2025-11-30 07:30:09 +01:00
parent 0b795d3c0f
commit e16c5589a8

View File

@@ -35,7 +35,7 @@ import traceback
import hashlib
from dataclasses import dataclass
from pathlib import Path
from typing import List, Dict, Optional, Set
from typing import List, Dict, Optional, Set, Any
# --- GUI imports
import tkinter as tk
@@ -389,6 +389,84 @@ Knowledge Base (source excerpts):
{KB}
""".strip()
REPHRASE_LENSES = [
{
"key": "neutral",
"label": "Neutral Clarification / Expansion",
"prompt": """Take the following rough note and turn it into a single clear, concise paragraph that captures the main idea.
- Keep a neutral, explanatory tone.
- Don't add new features or speculation, only clarify and connect what is already there.
- Output exactly one paragraph.
Note:
{USER_NOTE}
""",
},
{
"key": "problem_solution",
"label": "Problem-Solution Framing",
"prompt": """Rewrite the following note as a single paragraph that clearly describes:
1. What problem or frustration exists,
2. For whom,
3. How the idea could solve it in principle.
Keep it concrete but high-level, no implementation details.
Output exactly one paragraph.
Note:
{USER_NOTE}
""",
},
{
"key": "user_story",
"label": "User Story / Scenario",
"prompt": """Rewrite the following note as a single paragraph that describes a short scenario from a user's point of view.
Show how a specific person encounters the situation and how this idea helps them.
Keep it realistic and simple, not hype-y.
Output exactly one paragraph.
Note:
{USER_NOTE}
""",
},
{
"key": "value_prop",
"label": "Value Proposition / Pitch",
"prompt": """Rewrite the following note as a single paragraph that sounds like a clear, simple pitch of the idea.
Explain what it is, who it's for, and why it's valuable or interesting.
Avoid buzzwords; keep it grounded and concrete.
Output exactly one paragraph.
Note:
{USER_NOTE}
""",
},
{
"key": "implementation",
"label": "Implementation / Next Steps",
"prompt": """Rewrite the following note as a single paragraph that keeps the original idea but focuses on how one might start implementing or exploring it.
Mention 2-3 plausible first steps or components without going into deep technical detail.
Output exactly one paragraph.
Note:
{USER_NOTE}
""",
},
]
EXTEND_PROMPT = """
You are continuing the user's own note. Keep writing in the same language, tone, and formatting style they used.
Instructions:
- Extend the idea with additional possibilities, use cases, angles, or problems to consider.
- Preserve the author's voice: match their formality, punctuation habits, and quirks (e.g., all lowercase, terse bullets, or formal sentences).
- Do not summarize or rewrite the original; add new material that flows naturally after it.
- Keep it concise (2-5 sentences or a few short bullet points).
- If the input is in bullet form, continue the bullets; otherwise, continue the paragraph.
Original note:
{USER_NOTE}
""".strip()
def build_kb_string(records: List[Record], *, max_chars: int = 80000, per_record_cap: int = 4000) -> str:
"""Format records into a compact KB string with caps to avoid overlong prompts."""
@@ -481,6 +559,9 @@ class App(TkinterDnD.Tk): # type: ignore
self._files_dir: Path = self._base_dir / "files"
self._corpus_file: Path = self._base_dir / "corpus.jsonl"
self._sessions_file: Path = self._base_dir / "sessions.jsonl"
self.rephrase_variants: List[Dict[str, str]] = []
self.rephrase_selected_key: Optional[str] = None
self._suppress_rephrase_select: bool = False
# Defaults
self.ollama_host = tk.StringVar(value=os.environ.get("OLLAMA_HOST", "http://localhost:11434"))
@@ -503,6 +584,7 @@ class App(TkinterDnD.Tk): # type: ignore
# Prepare unified storage and indexes
self._init_storage()
self._build_ui()
self._refresh_rephrase_tree()
self._maybe_enable_dnd()
# Dirty tracking and close handler
self._dirty = False
@@ -520,6 +602,8 @@ class App(TkinterDnD.Tk): # type: ignore
"notes": "",
"concept": "",
"files": [],
"rephrase_variants": [],
"rephrase_selected_key": None,
}
def _set_app_icon(self) -> None:
@@ -652,10 +736,40 @@ class App(TkinterDnD.Tk): # type: ignore
# Connect scrollbar normally (always visible; resilient to squashing)
self.notes.configure(yscrollcommand=self.notes_vsb.set)
# No auto-hide for reliability
#
# Rephrase/variant list sits between the notes box and action buttons
self.rephrase_frame = ttk.Frame(notes_frame)
self.rephrase_frame.pack(side=tk.TOP, fill=tk.BOTH, expand=False, padx=(8,0), pady=(6,0))
ttk.Label(self.rephrase_frame, text="Rephrase variants:").pack(anchor=tk.W)
rephrase_list_container = ttk.Frame(self.rephrase_frame)
rephrase_list_container.pack(fill=tk.BOTH, expand=True)
try:
rephrase_list_container.rowconfigure(0, weight=1)
rephrase_list_container.columnconfigure(0, weight=1)
rephrase_list_container.columnconfigure(1, minsize=14)
except Exception:
pass
self.rephrase_tree = ttk.Treeview(rephrase_list_container, columns=("variant", "preview"), show="headings", height=6, selectmode="browse")
self.rephrase_tree.heading("variant", text="Variant")
self.rephrase_tree.heading("preview", text="Preview")
self.rephrase_tree.column("variant", width=140, anchor=tk.W)
self.rephrase_tree.column("preview", width=360, anchor=tk.W)
try:
self.rephrase_tree.grid(row=0, column=0, sticky="nsew")
except Exception:
self.rephrase_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
self.rephrase_vsb = ttk.Scrollbar(rephrase_list_container, orient=tk.VERTICAL, command=self.rephrase_tree.yview)
try:
self.rephrase_vsb.grid(row=0, column=1, sticky="ns")
except Exception:
self.rephrase_vsb.pack(side=tk.RIGHT, fill=tk.Y)
self.rephrase_tree.configure(yscrollcommand=self.rephrase_vsb.set)
self.rephrase_tree.bind("<<TreeviewSelect>>", self._on_rephrase_select)
notes_actions = ttk.Frame(notes_frame)
notes_actions.pack(side=tk.TOP, fill=tk.X, padx=(8,0), pady=(6,6))
ttk.Button(notes_actions, text="Generate Concept", command=self.on_generate).pack(side=tk.LEFT)
ttk.Button(notes_actions, text="Rephrase", command=self.on_rephrase).pack(side=tk.LEFT)
ttk.Button(notes_actions, text="Extend", command=self.on_extend).pack(side=tk.LEFT, padx=(6,0))
ttk.Button(notes_actions, text="Generate Concept", command=self.on_generate).pack(side=tk.LEFT, padx=(6,0))
ttk.Button(notes_actions, text="Find Prior Art", command=self.on_prior_art).pack(side=tk.LEFT, padx=(6,0))
# Concept editor + metadata
@@ -1113,6 +1227,172 @@ class App(TkinterDnD.Tk): # type: ignore
self._set_status(f"KB ready with {len(recs)} records")
return recs
# --- Notes rephrase / extend
def on_rephrase(self):
model = (self.ollama_model.get() or "").strip()
if not model or model == "Select model...":
self._ui(lambda: messagebox.showinfo("Select model", "Please select a model first."))
return
note = self.notes.get("1.0", tk.END).strip()
if not note:
self._ui(lambda: messagebox.showinfo("No notes", "Add some notes to rephrase."))
return
self._set_status("Rephrasing…")
threading.Thread(target=self._rephrase_thread, args=(note, model), daemon=True).start()
def _rephrase_thread(self, note: str, model: str):
try:
client = OllamaClient(host=self.ollama_host.get())
variants: List[Dict[str, str]] = [{
"key": "original",
"label": "Original Note",
"text": note,
}]
total = len(REPHRASE_LENSES)
for idx, lens in enumerate(REPHRASE_LENSES, start=1):
self._set_status(f"Rephrasing ({idx}/{total})…")
prompt = (lens.get("prompt") or "").replace("{USER_NOTE}", note)
try:
raw = client.generate(model=model, prompt=prompt)
except Exception as e:
raise RuntimeError(f"{lens.get('label','Variant')} failed: {e}")
text = sanitize_llm_text_simple(raw)
variants.append({
"key": lens.get("key") or f"lens_{idx}",
"label": lens.get("label") or f"Variant {idx}",
"text": text,
})
self._set_status("Rephrase complete")
self._ui(lambda v=variants: self._apply_rephrase_results(v, select_key="original"))
except Exception as e:
self._set_status("Rephrase failed")
msg = f"Rephrase failed:\n{e}"
self._ui(lambda m=msg: messagebox.showerror("Error", m))
def _apply_rephrase_results(self, variants: List[Dict[str, str]], *, select_key: Optional[str] = None, mark_dirty: bool = True):
self.rephrase_variants = variants
self.rephrase_selected_key = select_key or (variants[0].get("key") if variants else None)
self._refresh_rephrase_tree()
if mark_dirty:
self._set_dirty(True)
def _refresh_rephrase_tree(self):
if not hasattr(self, "rephrase_tree"):
return
try:
self.rephrase_tree.delete(*self.rephrase_tree.get_children())
except Exception:
return
if not self.rephrase_variants:
try:
self.rephrase_tree.insert('', tk.END, iid="placeholder", values=("No variants yet", "Click Rephrase to generate"))
except Exception:
pass
return
for v in self.rephrase_variants:
key = v.get("key") or ""
label = v.get("label") or key
preview = self._preview_rephrase_text(v.get("text", ""))
iid = key or str(id(v))
try:
self.rephrase_tree.insert('', tk.END, iid=iid, values=(label, preview))
except Exception:
# if iid already exists, fall back to autogenerated
try:
self.rephrase_tree.insert('', tk.END, values=(label, preview))
except Exception:
pass
key_to_select = self.rephrase_selected_key or (self.rephrase_variants[0].get("key") if self.rephrase_variants else None)
if key_to_select:
self._suppress_rephrase_select = True
try:
self.rephrase_tree.selection_set(key_to_select)
self.rephrase_tree.see(key_to_select)
except Exception:
pass
finally:
self._suppress_rephrase_select = False
@staticmethod
def _preview_rephrase_text(text: str, limit: int = 160) -> str:
clean = re.sub(r"\s+", " ", text or "").strip()
if len(clean) <= limit:
return clean
return clean[:limit].rstrip() + "..."
def _on_rephrase_select(self, _evt=None):
if self._suppress_rephrase_select:
return
sel = None
try:
sel = self.rephrase_tree.selection()
except Exception:
sel = None
if not sel:
return
key = sel[0]
if key == "placeholder":
return
variant = None
for v in self.rephrase_variants:
if (v.get("key") or "") == key:
variant = v
break
if not variant:
return
text = variant.get("text", "")
self.rephrase_selected_key = key
current = self.notes.get("1.0", tk.END).strip()
if current == text.strip():
return
self.notes.delete("1.0", tk.END)
self.notes.insert("1.0", text)
try:
self.notes.see("1.0")
except Exception:
pass
self._set_dirty(True)
def on_extend(self):
model = (self.ollama_model.get() or "").strip()
if not model or model == "Select model...":
self._ui(lambda: messagebox.showinfo("Select model", "Please select a model first."))
return
note = self.notes.get("1.0", tk.END).strip()
if not note:
self._ui(lambda: messagebox.showinfo("No notes", "Add some notes to extend."))
return
self._set_status("Extending…")
threading.Thread(target=self._extend_thread, args=(note, model), daemon=True).start()
def _extend_thread(self, note: str, model: str):
try:
client = OllamaClient(host=self.ollama_host.get())
prompt = EXTEND_PROMPT.replace("{USER_NOTE}", note)
raw = client.generate(model=model, prompt=prompt)
text = sanitize_llm_text_simple(raw)
if not text.strip():
raise RuntimeError("Empty response from model")
self._set_status("Extension ready")
def _apply():
existing = self.notes.get("1.0", tk.END)
if not existing.strip():
self.notes.delete("1.0", tk.END)
self.notes.insert("1.0", text)
else:
prefix = "\n" if existing.endswith("\n") else "\n"
self.notes.insert(tk.END, prefix + text)
try:
self.notes.see(tk.END)
except Exception:
pass
self._set_dirty(True)
self._ui(_apply)
except Exception as e:
self._set_status("Extend failed")
msg = f"Extend failed:\n{e}"
self._ui(lambda m=msg: messagebox.showerror("Error", m))
# --- Concept generation
def on_generate(self):
# Ensure a model is selected
@@ -1195,6 +1475,7 @@ class App(TkinterDnD.Tk): # type: ignore
self.desc_var.set("")
self.notes.delete("1.0", tk.END)
self.concept.delete("1.0", tk.END)
self._apply_rephrase_results([], mark_dirty=False)
self._set_status("New session")
self._set_dirty(False)
# Reset last-saved snapshot to clean baseline
@@ -1346,6 +1627,16 @@ class App(TkinterDnD.Tk): # type: ignore
self.notes.insert("1.0", s.get("notes", ""))
self.concept.delete("1.0", tk.END)
self.concept.insert("1.0", s.get("concept", ""))
rephrases_raw = s.get("rephrase_variants") or []
cleaned_rephrases: List[Dict[str, str]] = []
for v in rephrases_raw:
if isinstance(v, dict):
cleaned_rephrases.append({
"key": str(v.get("key") or ""),
"label": str(v.get("label") or ""),
"text": str(v.get("text") or ""),
})
self._apply_rephrase_results(cleaned_rephrases, select_key=s.get("rephrase_selected_key"), mark_dirty=False)
files = s.get("files") or []
# Re-add files; prefer original path, fallback to symlink by hash
@@ -2047,15 +2338,26 @@ class App(TkinterDnD.Tk): # type: ignore
} for p in self.files]
# stable order
files_list.sort(key=lambda x: (x["path"], not x["include"]))
variants: List[Dict[str, str]] = []
for v in (self.rephrase_variants or []):
if not isinstance(v, dict):
continue
variants.append({
"key": str(v.get("key") or ""),
"label": str(v.get("label") or ""),
"text": str(v.get("text") or ""),
})
return {
"title": (self.title_var.get() or "").strip(),
"description": (self.desc_var.get() or "").strip(),
"notes": self.notes.get("1.0", tk.END).strip(),
"concept": self.concept.get("1.0", tk.END).strip(),
"files": files_list,
"rephrase_variants": variants,
"rephrase_selected_key": self.rephrase_selected_key,
}
except Exception:
return {"title":"","description":"","notes":"","concept":"","files":[]}
return {"title":"","description":"","notes":"","concept":"","files":[],"rephrase_variants":[],"rephrase_selected_key":None}
def _is_effectively_dirty(self) -> bool:
try:
@@ -2179,6 +2481,12 @@ class App(TkinterDnD.Tk): # type: ignore
"concept": self.concept.get("1.0", tk.END).strip(),
"files": files_meta,
"saved_at": int(time.time()),
"rephrase_variants": [{
"key": str(v.get("key") or ""),
"label": str(v.get("label") or ""),
"text": str(v.get("text") or ""),
} for v in (self.rephrase_variants or []) if isinstance(v, dict)],
"rephrase_selected_key": self.rephrase_selected_key,
}
def _save_session(self, *, confirm_overwrite: bool, autosave: bool) -> bool:
@@ -2214,6 +2522,12 @@ class App(TkinterDnD.Tk): # type: ignore
"notes": self.notes.get("1.0", tk.END).strip(),
"concept": self.concept.get("1.0", tk.END).strip(),
"files": [{"path": str(p), "include": bool(self.include_map.get(str(p), True))} for p in self.files],
"rephrase_variants": [{
"key": str(v.get("key") or ""),
"label": str(v.get("label") or ""),
"text": str(v.get("text") or ""),
} for v in (self.rephrase_variants or []) if isinstance(v, dict)],
"rephrase_selected_key": self.rephrase_selected_key,
}
return True
except Exception as e: