From 4aad6aacc74a02af267ca4df7b3960dc05590965 Mon Sep 17 00:00:00 2001 From: Paulobarb Date: Thu, 26 Mar 2026 13:17:48 +0000 Subject: [PATCH 1/2] restore missing temperature assignments and clean frontmatter --- ai_backend.py | 38 +++++++++++++---------- config.py | 69 +++++++++++++++++++++++++---------------- generate_insights.py | 74 +++++++++++++++++++++++++++++--------------- morning_briefing.py | 15 +++++---- study_recap.py | 58 +++++++++++++++++++++------------- txt_to_notes.py | 23 +++++++++----- 6 files changed, 173 insertions(+), 104 deletions(-) diff --git a/ai_backend.py b/ai_backend.py index 869750c..ea36a8a 100644 --- a/ai_backend.py +++ b/ai_backend.py @@ -5,12 +5,14 @@ # All Ollama communication goes through this file. # ============================================================= -import os +from pathlib import Path import sys import requests -sys.path.insert(0, os.path.dirname(__file__)) -from config import OLLAMA_MODEL, OLLAMA_API_URL, TEMPERATURE, TIMEOUT, KEEP_ALIVE, NUM_CTX, VAULT_PATH +SCRIPT_DIR = Path(__file__).parent +sys.path.insert(0, str(SCRIPT_DIR)) + +from config import OLLAMA_MODEL, OLLAMA_API_URL, TEMPERATURES, TIMEOUT, KEEP_ALIVE, NUM_CTX, VAULT_PATH # noqa: E402 # terminal colors R = "\033[0m" @@ -48,13 +50,13 @@ def check_ollama() -> bool: def check_vault() -> bool: """ Check if the configured vault path exists. - Prints a friendly error if not found. + Prints a error if not found. Returns: True if vault exists, False otherwise. """ - vault = os.path.expanduser(VAULT_PATH) - if not os.path.isdir(vault): + vault = Path(VAULT_PATH).expanduser().resolve() + if not vault.is_dir(): print(f"{RED} Error: Vault not found at: {vault}{R}") print(" Update VAULT_PATH in config.py") return False @@ -81,7 +83,7 @@ def get_backend() -> str: return "ollama" -def call_ai(prompt: str, backend: str = "ollama", timeout: int = None) -> str: +def call_ai(prompt: str, backend: str = "ollama", timeout: int = None, temperature: float = None) -> str: """ Send a prompt to the configured AI backend and return the response text. @@ -89,24 +91,28 @@ def call_ai(prompt: str, backend: str = "ollama", timeout: int = None) -> str: prompt: The full prompt string to send to the model. backend: Which backend to use. Currently only 'ollama' is supported. timeout: Override the default timeout from config.py (in seconds). + temperature: Override the global temperature for this specific call. Returns: The model's response as a stripped string. """ - return _call_ollama(prompt, timeout or TIMEOUT) + return _call_ollama(prompt, timeout or TIMEOUT, temperature) -def _call_ollama(prompt: str, timeout: int) -> str: +def _call_ollama(prompt: str, timeout: int, temperature: float = None) -> str: """ Internal function that makes the actual HTTP request to the Ollama API. + """ - Args: - prompt: The prompt to send. - timeout: Max seconds to wait for a response. + global_temp = TEMPERATURES.get("default", 0.2) + raw_temp = temperature if temperature is not None else global_temp + + try: + final_temp = max(0.0, min(1.0, float(raw_temp))) + except (ValueError, TypeError): + print(f"\n[WARNING] Invalid temperature '{raw_temp}' in config. Defaulting to 0.2") + final_temp = 0.2 - Returns: - The model's response text, stripped of leading/trailing whitespace. - """ response = requests.post( OLLAMA_API_URL, json={ @@ -115,7 +121,7 @@ def _call_ollama(prompt: str, timeout: int) -> str: "stream": False, "keep_alive": KEEP_ALIVE, "options": { - "temperature": TEMPERATURE, + "temperature": final_temp, "top_p": 0.9, "repeat_penalty": 1.1, "num_ctx": NUM_CTX, diff --git a/config.py b/config.py index 83b47d8..216e50d 100644 --- a/config.py +++ b/config.py @@ -3,40 +3,60 @@ # ------------------------------------------------------------- OLLAMA_MODEL = "llama3.1:8b" # Ollama model to use -TEMPERATURE = 0.2 # Controls randomness. Lower = more factual, less hallucination. - # Range: 0.0 (deterministic) to 1.0 (creative). Default: 0.2 +# --- AI BEHAVIOR --- +# Temperature controls the randomness and creativity of the AI. +# - 0.0 to 0.3: Rigid, factual, analytical (Good for summaries/coding) +# - 0.4 to 0.7: Balanced, conversational (Good for general writing) +# - 0.8 to 1.0: Highly creative, unpredictable (Good for brainstorming) +TEMPERATURES = { + "default": 0.2, # The global fallback + + # Specific script overrides. Replace 'None' with a float (0.0-1.0) to override. + "insights": None, + "briefing": None, + "recap": None, + "txt": None, +} + +# --- FILENAME FORMATS --- +# Customize how your generated notes are named. +INSIGHT_TITLE_FORMAT = "{date} {period} Insight.md" +BRIEFING_TITLE_FORMAT = "{date} Morning Briefing.md" +RECAP_TITLE_FORMAT = "{date} ({time}) Study Recap — {subject}.md" +TXT_TITLE_FORMAT = "{title}.md" + +# --- TAGGING SYSTEM --- +# Words that trigger specific tags during AI synthesis (for generate_insights.py) +CANDIDATES = { + "productivity": ["productiv", "task", "goal", "work", "focus"], + "mood": ["mood", "emotion", "feel", "stress", "anxiet", "happy"], + "philosophy": ["meaning", "values", "purpose", "reflect", "life"], + "habits": ["habit", "routine", "pattern", "repeat", "daily"], + "health": ["health", "sleep", "exercise", "energy", "body"], + "projects": ["project", "build", "code", "ship", "launch", "develop"], + "relationships": ["friend", "family", "partner", "social", "connect"], +} # ------------------------------------------------------------- # API Configuration # ------------------------------------------------------------- -OLLAMA_API_URL = "http://localhost:11434/api/generate" # Ollama API endpoint - -TIMEOUT = 1000 # Max seconds to wait for a response per call. - # Increase for slow hardware or large prompts. Default: 1000 - -KEEP_ALIVE = "10m" # How long Ollama keeps the model loaded after last request. - # Format: "5m", "1h", "0" (unload immediately). Default: "10m" - -NUM_CTX = 8192 # Context window size in tokens. - # Higher = more notes fit in prompt but uses more RAM. - # Recommended: 4096 (fast) to 16384 (large vaults). Default: 8192 +OLLAMA_API_URL = "http://localhost:11434/api/generate" +TIMEOUT = 1000 +KEEP_ALIVE = "10m" +NUM_CTX = 8192 # ------------------------------------------------------------- # Vault Configuration # ------------------------------------------------------------- -VAULT_PATH = "~/Obsidian" # Path to your Obsidian vault. Supports ~ for home directory. +VAULT_PATH = "~/Obsidian" # Path to your Obsidian vault. # ------------------------------------------------------------- # Script Behaviour # ------------------------------------------------------------- -DAYS_BACK = 7 # generate_insights.py: how many days back to collect notes. - # 7 = weekly report, 30 = monthly report. - -HOURS_BACK = 24 # study_recap.py: how many hours back to auto-detect notes. - -MAX_NOTE_CHARS = 2000 # Max characters read per note. Higher = more detail but slower. - -MAX_FILE_SIZE = 1_000_000 # 1MB max per file, skip larger ones +DAYS_BACK = 7 +HOURS_BACK = 24 +MAX_NOTE_CHARS = 2000 +MAX_FILE_SIZE = 1_000_000 #-------------------------------------------------------------- # Ignore Folders @@ -46,7 +66,4 @@ "Insights", "Study Recaps", "Captures", - # add any folder you want to exclude here - # "Templates", - # "Archive", -] +] \ No newline at end of file diff --git a/generate_insights.py b/generate_insights.py index 11b07b9..d18e116 100644 --- a/generate_insights.py +++ b/generate_insights.py @@ -17,11 +17,12 @@ import sys import json +import re import datetime from pathlib import Path from concurrent.futures import ThreadPoolExecutor, as_completed -from config import MAX_FILE_SIZE, VAULT_PATH, DAYS_BACK, MAX_NOTE_CHARS, EXCLUDED_FOLDERS +from config import MAX_FILE_SIZE, VAULT_PATH, DAYS_BACK, MAX_NOTE_CHARS, EXCLUDED_FOLDERS, CANDIDATES, TEMPERATURES, INSIGHT_TITLE_FORMAT from ai_backend import get_backend, call_ai, backend_label, run_startup_checks SCRIPT_DIR = Path(__file__).parent @@ -36,9 +37,15 @@ with PROMPTS_PATH.open(encoding="utf-8") as f: PROMPTS = json.load(f) +TEMP_INSIGHTS = TEMPERATURES.get("insights") + GROUNDING = PROMPTS["grounding"] LENSES = PROMPTS["insights"]["lenses"] +def format_obsidian_tag(text: str) -> str: + text = text.lower().replace(" ", "-") + return re.sub(r'[^a-z0-9_-]', '', text) + def fill_prompt(template: str, **kwargs) -> str: """ Safe placeholder replacement that won't crash on literal { } in the template. @@ -51,8 +58,17 @@ def fill_prompt(template: str, **kwargs) -> str: return result def get_week_label() -> str: - """Return the current ISO week number as a string, e.g. 'Week 12'.""" - return f"Week {datetime.datetime.now().isocalendar()[1]}" + """Return a human-readable week range and number, e.g. 'Mar 23 – Mar 29 (Week 13)'.""" + now = datetime.datetime.now() + + start_of_week = now - datetime.timedelta(days=now.weekday()) + + end_of_week = start_of_week + datetime.timedelta(days=6) + + range_str = f"{start_of_week.strftime('%b %d')} – {end_of_week.strftime('%b %d')}" + week_num = now.isocalendar()[1] + + return f"{range_str} (Week {week_num})" # --- PERSISTENT STATE --- @@ -179,7 +195,7 @@ def run_lens(lens: dict, notes_block: str, period: str, backend: str) -> dict: notes_block=notes_block, ) print(f" Running lens: {lens['name']}...") - return {"name": lens["name"], "result": call_ai(prompt, backend)} + return {"name": lens["name"], "result": call_ai(prompt, backend, temperature=TEMP_INSIGHTS)} def run_synthesis(lens_results: list[dict], period: str, state: str, backend: str) -> tuple: @@ -215,7 +231,7 @@ def run_synthesis(lens_results: list[dict], period: str, state: str, backend: st ) print(" Running final synthesis...") - raw = call_ai(prompt, backend) + raw = call_ai(prompt, backend, temperature=TEMP_INSIGHTS) return extract_state_from_synthesis(raw) @@ -229,21 +245,18 @@ def extract_tags(lens_results: list[dict], synthesis: str) -> list[str]: base_tags = ["insights"] text = synthesis.lower() + " ".join(r["result"].lower() for r in lens_results) - candidates = { - "productivity": ["productiv", "task", "goal", "work", "focus"], - "mood": ["mood", "emotion", "feel", "stress", "anxiet", "happy"], - "philosophy": ["meaning", "values", "purpose", "reflect", "life"], - "habits": ["habit", "routine", "pattern", "repeat", "daily"], - "health": ["health", "sleep", "exercise", "energy", "body"], - "projects": ["project", "build", "code", "ship", "launch", "develop"], - "relationships": ["friend", "family", "partner", "social", "connect"], - } - - for tag, keywords in candidates.items(): + + for tag, keywords in CANDIDATES.items(): if any(kw in text for kw in keywords): base_tags.append(tag) - return base_tags[:6] + final_tags = [] + for tag in base_tags: + cleaned = format_obsidian_tag(tag) + if cleaned: + final_tags.append(cleaned) + + return final_tags[:6] # --- OUTPUT --- @@ -253,21 +266,32 @@ def write_insight_note(lens_results: list[dict], synthesis: str, note_count: int Write the final insight note to the Insights folder in the vault. Creates the folder if it doesn't exist. """ + now = datetime.datetime.now() date_str = datetime.datetime.now().strftime("%Y-%m-%d") + + is_month = DAYS_BACK > 7 period_label = "Week" if DAYS_BACK <= 7 else "Monthly" - week_label = get_week_label() - filename = f"{date_str} {period_label} Insight.md" + filename = INSIGHT_TITLE_FORMAT.format(date=date_str, period=period_label) + if is_month: + time_property = "month: " + now.strftime("%B %Y") + else: + time_property = "week: " + get_week_label() + INSIGHT_FOLDER.mkdir(parents=True, exist_ok=True) filepath = INSIGHT_FOLDER / filename - tags = extract_tags(lens_results, synthesis) - fm_lines = ( - ["---", "creation date: " + date_str, "tags:"] - + [f" - {t}" for t in tags] - + ["week: " + week_label, "content: insights", "---", "", ""] - ) + fm_lines = [ + "---", + f"creation date: {date_str}", + "tags:" + ] + [f" - {t}" for t in tags] + [ + time_property, + "content: insights", + "---", + "", "" + ] lines = ["## 🔮 Synthesis", "", synthesis, "", "---", ""] for r in lens_results: diff --git a/morning_briefing.py b/morning_briefing.py index 9954cb0..a16c3f3 100644 --- a/morning_briefing.py +++ b/morning_briefing.py @@ -16,7 +16,7 @@ import json import datetime -from config import EXCLUDED_FOLDERS, MAX_FILE_SIZE, VAULT_PATH, MAX_NOTE_CHARS +from config import EXCLUDED_FOLDERS, MAX_FILE_SIZE, VAULT_PATH, MAX_NOTE_CHARS, TEMPERATURES, BRIEFING_TITLE_FORMAT from ai_backend import get_backend, call_ai, backend_label, run_startup_checks SCRIPT_DIR = Path(__file__).parent @@ -30,6 +30,8 @@ PROMPTS_PATH = SCRIPT_DIR / "prompts.json" with PROMPTS_PATH.open(encoding="utf-8") as f: PROMPTS = json.load(f) + +TEMP_BRIEFING = TEMPERATURES.get("briefing") GROUNDING = PROMPTS["grounding"] @@ -46,8 +48,6 @@ def fill_prompt(template: str, **kwargs) -> str: def collect_notes(days_back: float) -> list[dict]: """ Collect notes modified within the last `days_back` days. - Skips Briefings and Insights folders to avoid feeding - generated content back into the briefing. Args: days_back: How many days back to look. Use 0 for today only, @@ -129,13 +129,12 @@ def generate_briefing(yesterday_notes: list[dict], today_notes: list[dict], back today_block=today_block, ) - return call_ai(prompt, backend) + return call_ai(prompt, backend, temperature=TEMP_BRIEFING) def write_briefing(content: str) -> str: """ Write the briefing to the Briefings folder in the vault. Creates the folder if it doesn't exist. - Frontmatter is built line by line to avoid f-string issues. Args: content: The briefing Markdown content from generate_briefing(). @@ -146,7 +145,7 @@ def write_briefing(content: str) -> str: BRIEFING_FOLDER.mkdir(parents=True, exist_ok=True) date_str = datetime.datetime.now().strftime("%Y-%m-%d") - filename = f"{date_str} Morning Briefing.md" + filename = BRIEFING_TITLE_FORMAT.format(date=date_str) filepath = BRIEFING_FOLDER / filename fm_lines = [ @@ -186,7 +185,7 @@ def main(): print(f" found {total} note(s) modified in the last 24h\n") if not yesterday_notes and not today_notes: - print(" no recent notes found — briefing will be minimal\n") + print(" no recent notes found\n") print(" generating briefing...") content = generate_briefing(yesterday_notes, today_notes, backend) @@ -196,4 +195,4 @@ def main(): if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/study_recap.py b/study_recap.py index b2fc52c..b39f223 100644 --- a/study_recap.py +++ b/study_recap.py @@ -13,8 +13,9 @@ import sys import json import datetime +import re -from config import EXCLUDED_FOLDERS, MAX_FILE_SIZE, VAULT_PATH, HOURS_BACK, MAX_NOTE_CHARS +from config import EXCLUDED_FOLDERS, MAX_FILE_SIZE, VAULT_PATH, HOURS_BACK, MAX_NOTE_CHARS, TEMPERATURES, RECAP_TITLE_FORMAT from ai_backend import get_backend, call_ai, backend_label, run_startup_checks SCRIPT_DIR = Path(__file__).parent @@ -27,6 +28,8 @@ with PROMPTS_PATH.open(encoding="utf-8") as f: PROMPTS = json.load(f) +TEMP_RECAP = TEMPERATURES.get("recap") + GROUNDING = PROMPTS["grounding"] R = "\033[0m" @@ -38,6 +41,11 @@ RED = "\033[31m" YELLOW = "\033[33m" +def format_obsidian_tag(text: str) -> str: + text = text.lower().replace(" ", "-") + + return re.sub(r'[^a-z0-9_-]', '', text) + def fill_prompt(template: str, **kwargs) -> str: """ @@ -92,14 +100,13 @@ def find_recent_notes(hours: int) -> list[dict]: def index_all_notes() -> dict[str, str]: """ Build an index of all notes in the vault for connection finding. - Only reads the first 500 chars of each note to keep memory usage low. """ notes = {} for path_obj in VAULT_PATH.rglob("*.md"): try: if path_obj.stat().st_size > MAX_FILE_SIZE: continue - content = path_obj.read_text(encoding="utf-8", errors="ignore")[:500] + content = path_obj.read_text(encoding="utf-8", errors="ignore")[:2000] notes[path_obj.stem] = content except Exception: continue @@ -129,6 +136,8 @@ def select_notes_interactively(auto_detected: list[dict]) -> list[dict]: print(f" {DIM}rm → remove a note{R}") print(f" {DIM}done → start generating{R}\n") + vault_index = {path.name.lower(): path for path in VAULT_PATH.rglob("*.md")} + while True: try: cmd = input(f"{CYAN}> {R}").strip() @@ -138,22 +147,24 @@ def select_notes_interactively(auto_detected: list[dict]) -> list[dict]: if cmd.lower() in ("done", ""): break elif cmd.startswith("add "): - filename = cmd[4:].strip() - if not filename.endswith(".md"): - filename += ".md" + target_filename = cmd[4:].strip().lower() + if not target_filename.endswith(".md"): + target_filename += ".md" + + if target_filename in vault_index: + path_obj = vault_index[target_filename] + + real_filename = path_obj.name - matches = list(VAULT_PATH.rglob(filename)) - if matches: - path_obj = matches[0] mtime = datetime.datetime.fromtimestamp(path_obj.stat().st_mtime) - note = {"name": filename, "path": path_obj, "mtime": mtime} - if filename not in [n["name"] for n in selected]: + note = {"name": real_filename, "path": path_obj, "mtime": mtime} + if real_filename not in [n["name"] for n in selected]: selected.append(note) - print(f" {GREEN}added: {filename}{R}") + print(f" {GREEN}added: {real_filename}{R}") else: print(f" {DIM}already in list{R}") else: - print(f" {RED}not found: {filename}{R}") + print(f" {RED}not found: {target_filename}{R}") elif cmd.startswith("rm "): try: idx = int(cmd[3:].strip()) - 1 @@ -182,7 +193,7 @@ def generate_recap(notes_data: list[dict], all_notes: dict, backend: str) -> str notes_block=notes_block, all_titles=all_titles, ) - return call_ai(prompt, backend, timeout=600) + return call_ai(prompt, backend, temperature=TEMP_RECAP) def write_recap(content: str, note_names: list[str]) -> str: @@ -190,18 +201,23 @@ def write_recap(content: str, note_names: list[str]) -> str: RECAP_FOLDER.mkdir(parents=True, exist_ok=True) date_str = datetime.datetime.now().strftime("%Y-%m-%d") - time_str = datetime.datetime.now().strftime("%H-%M") + time_str = datetime.datetime.now().strftime("%Hh%M") - base_name = Path(note_names[0]).stem if note_names else "Session" - if len(note_names) > 1: - base_name += f" +{len(note_names)-1} more" + stems = [Path(n).stem for n in note_names] + if len(stems) == 1: + subject = stems[0] + elif len(stems) == 2: + subject = f"{stems[0]} & {stems[1]}" + else: + subject = f"{stems[0]}, {stems[1]} (+{len(stems)-2})" - filename = f"{date_str} {time_str} Recap - {base_name}.md" + safe_subject = re.sub(r'[\\/*?:"<>|]', "", subject) + filename = RECAP_TITLE_FORMAT.format(date=date_str, time=time_str, subject=safe_subject) filepath = RECAP_FOLDER / filename tags_lines = [ - f" - {Path(n).stem.lower().replace(' ', '-')}" - for n in note_names[:5] + f" - {format_obsidian_tag(s)}" + for s in stems[:5] ] fm_lines = ( diff --git a/txt_to_notes.py b/txt_to_notes.py index d075216..a83f3db 100644 --- a/txt_to_notes.py +++ b/txt_to_notes.py @@ -3,7 +3,7 @@ # ============================================================= # Converts any text file into one or more structured Obsidian # notes. Works with conversations, meeting notes, articles, -# braindumps — anything you can paste into a .txt file. +# anything you can paste into a .txt file. # # Two-pass process: # Pass 1: AI reads the text and decides how many notes to create, @@ -25,7 +25,7 @@ import datetime from pathlib import Path -from config import MAX_FILE_SIZE, VAULT_PATH +from config import MAX_FILE_SIZE, VAULT_PATH, TEMPERATURES, TXT_TITLE_FORMAT from ai_backend import get_backend, call_ai, backend_label, run_startup_checks SCRIPT_DIR = Path(__file__).parent @@ -38,6 +38,9 @@ with PROMPTS_PATH.open(encoding="utf-8") as f: PROMPTS = json.load(f) + +TEMP_TXT = TEMPERATURES.get("txt") + GROUNDING = PROMPTS["grounding"] R = "\033[0m" @@ -48,6 +51,9 @@ GREEN = "\033[32m" RED = "\033[31m" +def format_obsidian_tag(text: str) -> str: + text = text.lower().replace(" ", "-") + return re.sub(r'[^a-z0-9_-]', '', text) def fill_prompt(template: str, **kwargs) -> str: """ @@ -104,7 +110,8 @@ def collect_vault_tags() -> list[str]: tags.add(t.lower()) except Exception: continue - return sorted(tags) + cleaned_tags = {format_obsidian_tag(t) for t in tags if format_obsidian_tag(t)} + return sorted(list(cleaned_tags)) def collect_note_titles() -> list[str]: @@ -143,7 +150,7 @@ def plan_notes(text, instructions, existing_tags, existing_titles, backend) -> l titles_hint=titles_hint, ) - raw = call_ai(prompt, backend) + raw = call_ai(prompt, backend, temperature=TEMP_TXT) raw = re.sub(r"```(?:json)?", "", raw).strip() match = re.search(r"\[.*\]", raw, re.DOTALL) @@ -178,15 +185,15 @@ def write_note_content(text, note_plan, all_titles_in_batch, instructions, exist related_links=related_links, tags_hint=tags_hint, ) - return call_ai(prompt, backend, timeout=300) + return call_ai(prompt, backend, temperature=TEMP_TXT) def write_file(note_plan, body, folder_path, date_str) -> str: """Write a single note to disk with Obsidian frontmatter.""" title = note_plan.get("title", "Untitled") - tags = [t.replace(" ", "-").lower() for t in note_plan.get("tags", [])] + tags = [format_obsidian_tag(t) for t in note_plan.get("tags", [])] safe_title = re.sub(r'[\\/*?:"<>|]', "", title) - filename = f"{safe_title}.md" + filename = TXT_TITLE_FORMAT.format(title=safe_title) filepath = Path(folder_path) / filename @@ -276,4 +283,4 @@ def main(): if __name__ == "__main__": - main() + main() \ No newline at end of file From 0cc97cab81e93e8519c5f6c2255710f3b2a31066 Mon Sep 17 00:00:00 2001 From: Paulobarb Date: Thu, 26 Mar 2026 13:34:04 +0000 Subject: [PATCH 2/2] a --- config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.py b/config.py index 216e50d..146c4d1 100644 --- a/config.py +++ b/config.py @@ -38,7 +38,7 @@ } # ------------------------------------------------------------- -# API Configuration +# API Configuration # ------------------------------------------------------------- OLLAMA_API_URL = "http://localhost:11434/api/generate" TIMEOUT = 1000