diff --git a/ai_backend.py b/ai_backend.py index 255abc6..ea36a8a 100644 --- a/ai_backend.py +++ b/ai_backend.py @@ -12,7 +12,7 @@ SCRIPT_DIR = Path(__file__).parent sys.path.insert(0, str(SCRIPT_DIR)) -from config import OLLAMA_MODEL, OLLAMA_API_URL, TEMPERATURE, TIMEOUT, KEEP_ALIVE, NUM_CTX, VAULT_PATH # noqa: E402 +from config import OLLAMA_MODEL, OLLAMA_API_URL, TEMPERATURES, TIMEOUT, KEEP_ALIVE, NUM_CTX, VAULT_PATH # noqa: E402 # terminal colors R = "\033[0m" @@ -50,7 +50,7 @@ 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. @@ -83,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. @@ -91,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={ @@ -117,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..4f4a98e 100644 --- a/config.py +++ b/config.py @@ -3,8 +3,21 @@ # ------------------------------------------------------------- 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 +# 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 = { + # The global fallback used if a script doesn't have a specific setting. + "default": 0.2, + + # Specific script overrides. + # Replace 'None' with a float to override the default. + "insights": None, # generate_insights.py + "briefing": None, # morning_briefing.py + "recap": None, # study_recap.py + "txt": None, # txt_to_notes.py +} # ------------------------------------------------------------- # API Configuration @@ -36,7 +49,7 @@ 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 +MAX_FILE_SIZE = 1_000_000 # 1MB max per file, skip larger ones #-------------------------------------------------------------- # Ignore Folders @@ -50,3 +63,34 @@ # "Templates", # "Archive", ] + +#-------------------------------------------------------------- +# Tagging System (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"], + # "finances": ["money", "budget", "spend", "invest", "finance"], + # Words that trigger specific tags + } + +# ------------------------------------------------------------- +# FILENAME FORMATS +# ------------------------------------------------------------- +# Customize how your generated notes are named. +# Placeholders: {date}, {period} +INSIGHT_TITLE_FORMAT = "{date} {period} Insight.md" + +# Placeholders: {date} +BRIEFING_TITLE_FORMAT = "{date} Morning Briefing.md" + +# Placeholders: {date}, {time}, {subject} +RECAP_TITLE_FORMAT = "{date} ({time}) Study Recap — {subject}.md" + +# Placeholders: {title} +TXT_TITLE_FORMAT = "{title}.md" \ No newline at end of file diff --git a/generate_insights.py b/generate_insights.py index 731ba3f..9fccc6e 100644 --- a/generate_insights.py +++ b/generate_insights.py @@ -21,7 +21,7 @@ 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 @@ -39,6 +39,8 @@ GROUNDING = PROMPTS["grounding"] LENSES = PROMPTS["insights"]["lenses"] +TEMP_INSIGHTS = TEMPERATURES.get("insights") + def fill_prompt(template: str, **kwargs) -> str: """ Safe placeholder replacement that won't crash on literal { } in the template. @@ -188,7 +190,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: @@ -224,7 +226,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) @@ -238,17 +240,8 @@ 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) @@ -267,14 +260,13 @@ def write_insight_note(lens_results: list[dict], synthesis: str, note_count: int is_month = DAYS_BACK > 7 period_label = "Week" if DAYS_BACK <= 7 else "Monthly" - 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) diff --git a/morning_briefing.py b/morning_briefing.py index 9954cb0..3042605 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 @@ -33,6 +33,8 @@ GROUNDING = PROMPTS["grounding"] +TEMP_BRIEFING = TEMPERATURES.get("briefing") + def fill_prompt(template: str, **kwargs) -> str: """ Safe placeholder replacement that won't crash on literal { } in the template. @@ -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 d8cefa6..a13b33b 100644 --- a/study_recap.py +++ b/study_recap.py @@ -15,7 +15,7 @@ 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 @@ -30,6 +30,8 @@ GROUNDING = PROMPTS["grounding"] +TEMP_RECAP = TEMPERATURES.get("recap") + R = "\033[0m" DIM = "\033[2m" BOLD = "\033[1m" @@ -98,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 @@ -192,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: @@ -210,8 +211,8 @@ def write_recap(content: str, note_names: list[str]) -> str: else: subject = f"{stems[0]}, {stems[1]} (+{len(stems)-2})" - filename = f"{date_str} {time_str} Recap - {subject}.md" - filename = re.sub(r'[\\/*?:"<>|]', "", filename) + 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 = [ diff --git a/txt_to_notes.py b/txt_to_notes.py index fc569f8..8494b89 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 @@ -48,6 +48,8 @@ GREEN = "\033[32m" RED = "\033[31m" +TEMP_TXT = TEMPERATURES.get("txt") + def format_obsidian_tag(text: str) -> str: text = text.lower().replace(" ", "-") return re.sub(r'[^a-z0-9_-]', '', text) @@ -147,7 +149,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) @@ -182,7 +184,7 @@ 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: @@ -190,7 +192,7 @@ def write_file(note_plan, body, folder_path, date_str) -> str: title = note_plan.get("title", "Untitled") 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 @@ -280,4 +282,4 @@ def main(): if __name__ == "__main__": - main() + main() \ No newline at end of file