Spaces:
Sleeping
Sleeping
# app.py | |
import os | |
import json | |
import re | |
import logging | |
import threading | |
from datetime import datetime | |
from dotenv import load_dotenv | |
import gradio as gr | |
import time | |
import tempfile | |
load_dotenv() | |
from model_logic import ( | |
get_available_providers, get_model_display_names_for_provider, | |
get_default_model_display_name_for_provider, call_model_stream, MODELS_BY_PROVIDER | |
) | |
from memory_logic import ( | |
initialize_memory_system, | |
add_memory_entry, retrieve_memories_semantic, get_all_memories_cached, clear_all_memory_data_backend, | |
add_rule_entry, retrieve_rules_semantic, remove_rule_entry, get_all_rules_cached, clear_all_rules_data_backend, | |
save_faiss_indices_to_disk, STORAGE_BACKEND as MEMORY_STORAGE_BACKEND, SQLITE_DB_PATH as MEMORY_SQLITE_PATH, | |
HF_MEMORY_DATASET_REPO as MEMORY_HF_MEM_REPO, HF_RULES_DATASET_REPO as MEMORY_HF_RULES_REPO | |
) | |
from websearch_logic import scrape_url, search_and_scrape_duckduckgo, search_and_scrape_google | |
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(threadName)s - %(message)s') | |
logger = logging.getLogger(__name__) | |
for lib_name in ["urllib3", "requests", "huggingface_hub", "PIL.PngImagePlugin", "matplotlib", "gradio_client.client", "multipart.multipart", "httpx", "sentence_transformers", "faiss", "datasets"]: | |
if logging.getLogger(lib_name): logging.getLogger(lib_name).setLevel(logging.WARNING) | |
WEB_SEARCH_ENABLED = os.getenv("WEB_SEARCH_ENABLED", "true").lower() == "true" | |
TOOL_DECISION_PROVIDER_ENV = os.getenv("TOOL_DECISION_PROVIDER", "groq") | |
TOOL_DECISION_MODEL_ID_ENV = os.getenv("TOOL_DECISION_MODEL", "llama3-8b-8192") | |
MAX_HISTORY_TURNS = int(os.getenv("MAX_HISTORY_TURNS", 7)) | |
current_chat_session_history = [] | |
DEFAULT_SYSTEM_PROMPT = os.getenv( | |
"DEFAULT_SYSTEM_PROMPT", | |
"You are a helpful AI research assistant. Your primary goal is to answer questions and perform research tasks accurately and thoroughly. You can use tools like web search and page browsing. When providing information from the web, cite your sources if possible. If asked to perform a task beyond your capabilities, explain politely. Be concise unless asked for detail." | |
) | |
logger.info(f"App Config: WebSearch={WEB_SEARCH_ENABLED}, ToolDecisionProvider={TOOL_DECISION_PROVIDER_ENV}, ToolDecisionModelID={TOOL_DECISION_MODEL_ID_ENV}, MemoryBackend={MEMORY_STORAGE_BACKEND}") | |
# --- Helper Functions (format_insights_for_prompt, generate_interaction_metrics, etc.) --- | |
def format_insights_for_prompt(retrieved_insights_list: list[str]) -> tuple[str, list[dict]]: | |
if not retrieved_insights_list: | |
return "No specific guiding principles or learned insights retrieved.", [] | |
parsed = [] | |
for text in retrieved_insights_list: | |
match = re.match(r"\[(CORE_RULE|RESPONSE_PRINCIPLE|BEHAVIORAL_ADJUSTMENT|GENERAL_LEARNING)\|([\d\.]+?)\](.*)", text.strip(), re.DOTALL | re.IGNORECASE) | |
if match: | |
parsed.append({"type": match.group(1).upper().replace(" ", "_"), "score": match.group(2), "text": match.group(3).strip(), "original": text.strip()}) | |
else: | |
parsed.append({"type": "GENERAL_LEARNING", "score": "0.5", "text": text.strip(), "original": text.strip()}) | |
try: | |
parsed.sort(key=lambda x: float(x["score"]) if x["score"].replace('.', '', 1).isdigit() else -1.0, reverse=True) | |
except ValueError: logger.warning("FORMAT_INSIGHTS: Sort error due to invalid score format.") | |
grouped = {"CORE_RULE": [], "RESPONSE_PRINCIPLE": [], "BEHAVIORAL_ADJUSTMENT": [], "GENERAL_LEARNING": []} | |
for p_item in parsed: grouped.get(p_item["type"], grouped["GENERAL_LEARNING"]).append(f"- (Score: {p_item['score']}) {p_item['text']}") | |
sections = [f"{k.replace('_', ' ').title()}:\n" + "\n".join(v) for k, v in grouped.items() if v] | |
return "\n\n".join(sections) if sections else "No guiding principles retrieved.", parsed | |
def generate_interaction_metrics(user_input: str, bot_response: str, provider: str, model_display_name: str, api_key_override: str = None) -> dict: | |
metric_start_time = time.time() | |
logger.info(f"Generating metrics with: {provider}/{model_display_name}") | |
metric_prompt_content = f"User: \"{user_input}\"\nAI: \"{bot_response}\"\nMetrics: \"takeaway\" (3-7 words), \"response_success_score\" (0.0-1.0), \"future_confidence_score\" (0.0-1.0). Output JSON ONLY, ensure it's a single, valid JSON object." | |
metric_messages = [{"role": "system", "content": "You are a precise JSON output agent. Output a single JSON object containing interaction metrics as requested by the user. Do not include any explanatory text before or after the JSON object."}, {"role": "user", "content": metric_prompt_content}] | |
try: | |
metrics_provider_final, metrics_model_display_final = provider, model_display_name | |
metrics_model_env = os.getenv("METRICS_MODEL") | |
if metrics_model_env and "/" in metrics_model_env: | |
m_prov, m_id = metrics_model_env.split('/', 1) | |
m_disp_name = next((dn for dn, mid in MODELS_BY_PROVIDER.get(m_prov.lower(), {}).get("models", {}).items() if mid == m_id), None) | |
if m_disp_name: metrics_provider_final, metrics_model_display_final = m_prov, m_disp_name | |
else: logger.warning(f"METRICS_MODEL '{metrics_model_env}' not found, using interaction model.") | |
response_chunks = list(call_model_stream(provider=metrics_provider_final, model_display_name=metrics_model_display_final, messages=metric_messages, api_key_override=api_key_override, temperature=0.05, max_tokens=200)) | |
resp_str = "".join(response_chunks).strip() | |
json_match = re.search(r"```json\s*(\{.*?\})\s*```", resp_str, re.DOTALL | re.IGNORECASE) or re.search(r"(\{.*?\})", resp_str, re.DOTALL) | |
if json_match: metrics_data = json.loads(json_match.group(1)) | |
else: | |
logger.warning(f"METRICS_GEN: Non-JSON response from {metrics_provider_final}/{metrics_model_display_final}: '{resp_str}'") | |
return {"takeaway": "N/A", "response_success_score": 0.5, "future_confidence_score": 0.5, "error": "metrics format error"} | |
parsed_metrics = {"takeaway": metrics_data.get("takeaway", "N/A"), "response_success_score": float(metrics_data.get("response_success_score", 0.5)), "future_confidence_score": float(metrics_data.get("future_confidence_score", 0.5)), "error": metrics_data.get("error")} | |
logger.info(f"METRICS_GEN: Generated in {time.time() - metric_start_time:.2f}s. Data: {parsed_metrics}") | |
return parsed_metrics | |
except Exception as e: | |
logger.error(f"METRICS_GEN Error: {e}", exc_info=False) | |
return {"takeaway": "N/A", "response_success_score": 0.5, "future_confidence_score": 0.5, "error": str(e)} | |
def process_user_interaction_gradio(user_input: str, provider_name: str, model_display_name: str, chat_history_for_prompt: list[dict], custom_system_prompt: str = None, ui_api_key_override: str = None): | |
process_start_time = time.time() | |
request_id = os.urandom(4).hex() | |
logger.info(f"PUI_GRADIO [{request_id}] Start. User: '{user_input[:50]}...' Provider: {provider_name}/{model_display_name} Hist_len:{len(chat_history_for_prompt)}") | |
history_str_for_prompt = "\n".join([f"{('User' if t_msg['role'] == 'user' else 'AI')}: {t_msg['content']}" for t_msg in chat_history_for_prompt[-(MAX_HISTORY_TURNS * 2):]]) | |
yield "status", "<i>[Checking guidelines (semantic search)...]</i>" | |
initial_insights = retrieve_rules_semantic(f"{user_input}\n{history_str_for_prompt}", k=5) | |
initial_insights_ctx_str, parsed_initial_insights_list = format_insights_for_prompt(initial_insights) | |
logger.info(f"PUI_GRADIO [{request_id}]: Initial RAG (insights) found {len(initial_insights)}. Context: {initial_insights_ctx_str[:150]}...") | |
action_type, action_input_dict = "quick_respond", {} | |
user_input_lower = user_input.lower() | |
time_before_tool_decision = time.time() | |
if WEB_SEARCH_ENABLED and ("http://" in user_input or "https://" in user_input): | |
url_match = re.search(r'(https?://[^\s]+)', user_input) | |
if url_match: action_type, action_input_dict = "scrape_url_and_report", {"url": url_match.group(1)} | |
if action_type == "quick_respond" and len(user_input.split()) <= 3 and any(kw in user_input_lower for kw in ["hello", "hi", "thanks", "ok", "bye"]) and not "?" in user_input: pass | |
elif action_type == "quick_respond" and WEB_SEARCH_ENABLED and (len(user_input.split()) > 3 or "?" in user_input or any(w in user_input_lower for w in ["what is", "how to", "explain", "search for"])): | |
yield "status", "<i>[LLM choosing best approach...]</i>" | |
history_snippet = "\n".join([f"{msg['role']}: {msg['content'][:100]}" for msg in chat_history_for_prompt[-2:]]) | |
guideline_snippet = initial_insights_ctx_str[:200].replace('\n', ' ') | |
tool_sys_prompt = "You are a precise routing agent... Output JSON only. Example: {\"action\": \"search_duckduckgo_and_report\", \"action_input\": {\"search_engine_query\": \"query\"}}" | |
tool_user_prompt = f"User Query: \"{user_input}\"\nRecent History:\n{history_snippet}\nGuidelines: {guideline_snippet}...\nAvailable Actions: quick_respond, answer_using_conversation_memory, search_duckduckgo_and_report, scrape_url_and_report.\nSelect one action and input. Output JSON." | |
tool_decision_messages = [{"role":"system", "content": tool_sys_prompt}, {"role":"user", "content": tool_user_prompt}] | |
tool_provider, tool_model_id = TOOL_DECISION_PROVIDER_ENV, TOOL_DECISION_MODEL_ID_ENV | |
tool_model_display = next((dn for dn, mid in MODELS_BY_PROVIDER.get(tool_provider.lower(), {}).get("models", {}).items() if mid == tool_model_id), None) | |
if not tool_model_display: tool_model_display = get_default_model_display_name_for_provider(tool_provider) | |
if tool_model_display: | |
try: | |
logger.info(f"PUI_GRADIO [{request_id}]: Tool decision LLM: {tool_provider}/{tool_model_display}") | |
tool_resp_chunks = list(call_model_stream(provider=tool_provider, model_display_name=tool_model_display, messages=tool_decision_messages, temperature=0.0, max_tokens=150)) | |
tool_resp_raw = "".join(tool_resp_chunks).strip() | |
json_match_tool = re.search(r"\{.*\}", tool_resp_raw, re.DOTALL) | |
if json_match_tool: | |
action_data = json.loads(json_match_tool.group(0)) | |
action_type, action_input_dict = action_data.get("action", "quick_respond"), action_data.get("action_input", {}) | |
if not isinstance(action_input_dict, dict): action_input_dict = {} | |
logger.info(f"PUI_GRADIO [{request_id}]: LLM Tool Decision: Action='{action_type}', Input='{action_input_dict}'") | |
else: logger.warning(f"PUI_GRADIO [{request_id}]: Tool decision LLM non-JSON. Raw: {tool_resp_raw}") | |
except Exception as e: logger.error(f"PUI_GRADIO [{request_id}]: Tool decision LLM error: {e}", exc_info=False) | |
else: logger.error(f"No model for tool decision provider {tool_provider}.") | |
elif action_type == "quick_respond" and not WEB_SEARCH_ENABLED and (len(user_input.split()) > 4 or "?" in user_input or any(w in user_input_lower for w in ["remember","recall"])): | |
action_type="answer_using_conversation_memory" | |
logger.info(f"PUI_GRADIO [{request_id}]: Tool decision logic took {time.time() - time_before_tool_decision:.3f}s. Action: {action_type}, Input: {action_input_dict}") | |
yield "status", f"<i>[Path: {action_type}. Preparing response...]</i>" | |
final_system_prompt_str, final_user_prompt_content_str = custom_system_prompt or DEFAULT_SYSTEM_PROMPT, "" | |
if action_type == "quick_respond": | |
final_system_prompt_str += " Respond directly using guidelines & history." | |
final_user_prompt_content_str = f"History:\n{history_str_for_prompt}\nGuidelines:\n{initial_insights_ctx_str}\nQuery: \"{user_input}\"\nResponse:" | |
elif action_type == "answer_using_conversation_memory": | |
yield "status", "<i>[Searching conversation memory (semantic)...]</i>" | |
retrieved_mems = retrieve_memories_semantic(f"User query: {user_input}\nContext:\n{history_str_for_prompt[-1000:]}", k=2) | |
memory_context = "Relevant Past Interactions:\n" + "\n".join([f"- User:{m.get('user_input','')}->AI:{m.get('bot_response','')} (Takeaway:{m.get('metrics',{}).get('takeaway','N/A')})" for m in retrieved_mems]) if retrieved_mems else "No relevant past interactions found." | |
final_system_prompt_str += " Respond using Memory Context, guidelines, & history." | |
final_user_prompt_content_str = f"History:\n{history_str_for_prompt}\nGuidelines:\n{initial_insights_ctx_str}\nMemory Context:\n{memory_context}\nQuery: \"{user_input}\"\nResponse (use memory context if relevant):" | |
elif WEB_SEARCH_ENABLED and action_type in ["search_duckduckgo_and_report", "scrape_url_and_report"]: | |
query_or_url = action_input_dict.get("search_engine_query") if "search" in action_type else action_input_dict.get("url") | |
if not query_or_url: | |
final_system_prompt_str += " Respond directly (web action failed: no input)." | |
final_user_prompt_content_str = f"History:\n{history_str_for_prompt}\nGuidelines:\n{initial_insights_ctx_str}\nQuery: \"{user_input}\"\nResponse:" | |
else: | |
yield "status", f"<i>[Web: '{query_or_url[:60]}'...]</i>" | |
web_results, max_results = [], 1 if action_type == "scrape_url_and_report" else 2 | |
try: | |
if action_type == "search_duckduckgo_and_report": web_results = search_and_scrape_duckduckgo(query_or_url, num_results=max_results) | |
elif action_type == "scrape_url_and_report": | |
res = scrape_url(query_or_url) | |
if res and (res.get("content") or res.get("error")): web_results = [res] | |
except Exception as e: web_results = [{"url": query_or_url, "title": "Tool Error", "error": str(e)}] | |
scraped_content = "\n".join([f"Source {i+1}:\nURL:{r.get('url','N/A')}\nTitle:{r.get('title','N/A')}\nContent:\n{(r.get('content') or r.get('error') or 'N/A')[:3500]}\n---" for i,r in enumerate(web_results)]) if web_results else f"No results from {action_type} for '{query_or_url}'." | |
yield "status", "<i>[Synthesizing web report...]</i>" | |
final_system_prompt_str += " Generate report/answer from web content, history, & guidelines. Cite URLs as [Source X]." | |
final_user_prompt_content_str = f"History:\n{history_str_for_prompt}\nGuidelines:\n{initial_insights_ctx_str}\nWeb Content:\n{scraped_content}\nQuery: \"{user_input}\"\nReport/Response (cite sources [Source X]):" | |
else: | |
final_system_prompt_str += " Respond directly (unknown action path)." | |
final_user_prompt_content_str = f"History:\n{history_str_for_prompt}\nGuidelines:\n{initial_insights_ctx_str}\nQuery: \"{user_input}\"\nResponse:" | |
final_llm_messages = [{"role": "system", "content": final_system_prompt_str}, {"role": "user", "content": final_user_prompt_content_str}] | |
logger.debug(f"PUI_GRADIO [{request_id}]: Final LLM System Prompt: {final_system_prompt_str[:200]}...") | |
logger.debug(f"PUI_GRADIO [{request_id}]: Final LLM User Prompt Start: {final_user_prompt_content_str[:200]}...") | |
streamed_response, time_before_llm = "", time.time() | |
try: | |
for chunk in call_model_stream(provider=provider_name, model_display_name=model_display_name, messages=final_llm_messages, api_key_override=ui_api_key_override, temperature=0.6, max_tokens=2500): | |
if isinstance(chunk, str) and chunk.startswith("Error:"): streamed_response += f"\n{chunk}\n"; yield "response_chunk", f"\n{chunk}\n"; break | |
streamed_response += chunk; yield "response_chunk", chunk | |
except Exception as e: streamed_response += f"\n\n(Error: {str(e)[:150]})"; yield "response_chunk", f"\n\n(Error: {str(e)[:150]})" | |
logger.info(f"PUI_GRADIO [{request_id}]: Main LLM stream took {time.time() - time_before_llm:.3f}s.") | |
final_bot_text = streamed_response.strip() or "(No response or error.)" | |
logger.info(f"PUI_GRADIO [{request_id}]: Finished. Total: {time.time() - process_start_time:.2f}s. Resp len: {len(final_bot_text)}") | |
yield "final_response_and_insights", {"response": final_bot_text, "insights_used": parsed_initial_insights_list} | |
def repair_json_string_newslines(json_like_string: str) -> str: | |
""" | |
Attempts to fix unescaped literal newlines within string literals in a JSON-like string. | |
This is a heuristic and focuses on the most common LLM error for this task. | |
It converts literal \n, \r, \r\n characters found *inside* what it | |
determines to be string literals into the two-character sequence "\\n". | |
""" | |
output = [] | |
i = 0 | |
n = len(json_like_string) | |
in_string_literal = False | |
while i < n: | |
char = json_like_string[i] | |
if char == '"': | |
# Check if this quote is escaped (preceded by an odd number of backslashes) | |
num_backslashes = 0 | |
k = i - 1 | |
while k >= 0 and json_like_string[k] == '\\': | |
num_backslashes += 1 | |
k -= 1 | |
if num_backslashes % 2 == 0: # This quote is not escaped, it's a delimiter | |
in_string_literal = not in_string_literal | |
output.append(char) | |
i += 1 | |
continue | |
if in_string_literal: | |
if char == '\n': # Literal newline | |
output.append('\\\\n') # Append literal backslash then 'n' | |
elif char == '\r': | |
if i + 1 < n and json_like_string[i+1] == '\n': # CRLF | |
output.append('\\\\n') | |
i += 1 # Consume the \n as well, it's part of the CRLF pair | |
else: # Standalone CR | |
output.append('\\\\n') | |
# We are not attempting to fix other escape issues here, | |
# as newlines are the primary cause of "Unterminated string". | |
# If we see a backslash, we assume it's either a correct escape | |
# or part of content that doesn't break basic parsing like a raw newline does. | |
else: | |
output.append(char) | |
else: # Not in string literal | |
output.append(char) | |
i += 1 | |
return "".join(output) | |
def deferred_learning_and_memory_task(user_input: str, bot_response: str, provider: str, model_disp_name: str, insights_reflected: list[dict], api_key_override: str = None): | |
start_time, task_id = time.time(), os.urandom(4).hex() | |
logger.info(f"DEFERRED [{task_id}]: START User='{user_input[:40]}...', Bot='{bot_response[:40]}...'") | |
try: | |
metrics = generate_interaction_metrics(user_input, bot_response, provider, model_disp_name, api_key_override) | |
logger.info(f"DEFERRED [{task_id}]: Metrics: {metrics}") | |
add_memory_entry(user_input, metrics, bot_response) | |
summary = f"User:\"{user_input}\"\nAI:\"{bot_response}\"\nMetrics(takeaway):{metrics.get('takeaway','N/A')},Success:{metrics.get('response_success_score','N/A')}" | |
existing_rules_ctx = "\n".join([f"- \"{r}\"" for r in retrieve_rules_semantic(f"{summary}\n{user_input}", k=10)]) or "No existing rules context." | |
insight_sys_prompt = """You are an expert AI knowledge base curator. Your primary function is to meticulously analyze an interaction and update the AI's guiding principles (insights/rules) to improve its future performance and self-understanding. | |
**CRITICAL OUTPUT REQUIREMENT: You MUST output a single, valid JSON list of operation objects.** | |
This list can and SHOULD contain MULTIPLE distinct operations if various learnings occurred. | |
If no operations are warranted, output an empty JSON list: `[]`. | |
ABSOLUTELY NO other text, explanations, or markdown should precede or follow this JSON list. | |
Your output will be directly parsed by Python's `json.loads()` function. | |
Each operation object in the JSON list must have these keys and string values: | |
1. `"action"`: A string, either `"add"` (for entirely new rules) or `"update"` (to replace an existing rule with a better one). | |
2. `"insight"`: A string, the full, refined insight text including its `[TYPE|SCORE]` prefix (e.g., `"[CORE_RULE|1.0] My name is Lumina, an AI assistant."`). | |
3. `"old_insight_to_replace"`: (ONLY for `"update"` action) A string, the *exact, full text* of an existing insight that the new `"insight"` should replace. If action is `"add"`, this key should be omitted or its value should be `null` or an empty string. | |
**ULTRA-CRITICAL JSON STRING FORMATTING RULES (ESPECIALLY FOR THE "insight" FIELD):** | |
- All string values MUST be enclosed in double quotes (`"`). | |
- Any literal double quote (`"`) character *within* the string content MUST be escaped as `\\"`. | |
- Any literal backslash (`\\`) character *within* the string content MUST be escaped as `\\\\`. | |
- **NEWLINES ARE THE #1 CAUSE OF ERRORS. READ CAREFULLY:** | |
- If the *content* of your `insight` text (or any other string value) spans multiple lines OR contains newline characters for formatting, EACH such newline character *inside the string's content* MUST be represented as the two-character sequence: a backslash followed by an 'n' (i.e., `\\n`). | |
- **DO NOT use a literal newline character (pressing Enter) inside a JSON string value.** This will break the JSON parser and result in an "Unterminated string" error. | |
- **Example of BAD JSON (literal newline):** | |
`"insight": "This is line one. | |
This is line two."` | |
(This is INVALID because of the actual newline between "one." and "This". The parser stops reading the string at "one.") | |
- **Example of GOOD JSON (escaped newline using `\\n`):** | |
`"insight": "This is line one.\\nThis is line two."` | |
(This is VALID. The `\\n` sequence correctly tells the parser there's a newline *within* the string's content.) | |
- The entire JSON response (the list `[...]`) can be pretty-printed with newlines *between* JSON elements (like after a comma or a brace) for readability, but *within* any quoted string value, the newline rule above is absolute. | |
**Your Reflection Process (Consider each step and generate operations accordingly):** | |
(rest of reflection process - STEP 1, STEP 2, STEP 3 - remains the same) ... | |
**General Guidelines for Insight Content and Actions:** | |
(General Guidelines remain the same) ... | |
**Example of a comprehensive JSON output (Pay close attention to `\\n` for newlines within insight text if the insight content spans multiple lines):** | |
[ | |
{"action": "update", "old_insight_to_replace": "[CORE_RULE|1.0] My designated name is 'LearnerAI'.", "insight": "[CORE_RULE|1.0] I am Lumina, an AI assistant designed to chat, provide information, and remember context like the secret word 'rocksyrup'."}, | |
{"action": "update", "old_insight_to_replace": "[CORE_RULE|1.0] I'm Lumina, the AI designed to chat with you.", "insight": "[CORE_RULE|1.0] I am Lumina, an AI assistant designed to chat, provide information, and remember context like the secret word 'rocksyrup'.\\nMy purpose is to assist the user with research and tasks."}, | |
{"action": "add", "insight": "[CORE_RULE|0.9] I am capable of searching the internet for current weather information if asked.\\nThis capability was confirmed on [date] based on user query regarding weather."}, | |
{"action": "add", "insight": "[RESPONSE_PRINCIPLE|0.8] When user provides positive feedback like 'good job', acknowledge it warmly with phrases like 'Thank you!' or 'Glad I could help!'."}, | |
{"action": "update", "old_insight_to_replace": "[RESPONSE_PRINCIPLE|0.7] Avoid mentioning old conversations.", "insight": "[RESPONSE_PRINCIPLE|0.85] Avoid mentioning old conversations unless the user explicitly refers to them or it's highly relevant to the current query.\\nThis rule was updated due to user preference for more contextual continuity when appropriate."} | |
] | |
""" | |
insight_user_prompt = f"""Interaction Summary:\n{summary}\n | |
Potentially Relevant Existing Rules (Review these carefully. Your main goal is to consolidate CORE_RULEs and then identify other changes/additions based on the Interaction Summary and these existing rules):\n{existing_rules_ctx}\n | |
Guiding principles that were considered during THIS interaction (these might offer clues for new rules or refinements):\n{json.dumps([p['original'] for p in insights_reflected if 'original' in p]) if insights_reflected else "None"}\n | |
Task: Based on your three-step reflection process (Core Identity, New Learnings, Refinements): | |
1. **Consolidate CORE_RULEs:** Merge similar identity/purpose rules from "Potentially Relevant Existing Rules" into single, definitive statements using "update" operations. Replace multiple old versions with the new canonical one. | |
2. **Add New Learnings:** Identify and "add" any distinct new facts, skills, or important user preferences learned from the "Interaction Summary". | |
3. **Update Existing Principles:** "Update" any non-core principles from "Potentially Relevant Existing Rules" if the "Interaction Summary" provided a clear refinement. | |
Combine all findings into a single JSON list of operations. If there are multiple distinct changes based on the interaction and existing rules, ensure your list reflects all of them. Output JSON only, adhering to all specified formatting rules. | |
**ULTRA-IMPORTANT FINAL REMINDER: YOUR ENTIRE RESPONSE MUST BE A SINGLE, VALID JSON LIST. DOUBLE-CHECK ALL STRING VALUES, ESPECIALLY THE 'insight' TEXT, FOR CORRECTLY ESCAPED NEWLINES (using the two characters `\\` and `n`, i.e., `\\n`) AND QUOTES (using `\\"`). AN UNESCAPED LITERAL NEWLINE CHARACTER (ASCII 10) WITHIN AN 'insight' STRING VALUE WILL BREAK THE JSON AND CAUSE THE UPDATE TO FAIL. BE METICULOUS. VALIDATE YOUR JSON STRUCTURE AND STRING CONTENTS BEFORE FINALIZING YOUR OUTPUT.** | |
""" | |
insight_msgs = [{"role":"system", "content":insight_sys_prompt}, {"role":"user", "content":insight_user_prompt}] | |
insight_prov, insight_model_disp = provider, model_disp_name | |
insight_env_model = os.getenv("INSIGHT_MODEL_OVERRIDE") | |
if insight_env_model and "/" in insight_env_model: | |
i_p, i_id = insight_env_model.split('/', 1) | |
i_d_n = next((dn for dn, mid in MODELS_BY_PROVIDER.get(i_p.lower(), {}).get("models", {}).items() if mid == i_id), None) | |
if i_d_n: insight_prov, insight_model_disp = i_p, i_d_n | |
logger.info(f"DEFERRED [{task_id}]: Generating insights with {insight_prov}/{insight_model_disp}") | |
raw_ops_json_full = "".join(list(call_model_stream(provider=insight_prov, model_display_name=insight_model_disp, messages=insight_msgs, api_key_override=api_key_override, temperature=0.0, max_tokens=2000))).strip() | |
ops, processed_count = [], 0 | |
json_match_ops = re.search(r"```json\s*(\[.*?\])\s*```", raw_ops_json_full, re.DOTALL|re.I) or \ | |
re.search(r"(\[.*?\])", raw_ops_json_full, re.DOTALL) | |
ops_json_str = None | |
if json_match_ops: | |
ops_json_str = json_match_ops.group(1) | |
try: | |
ops = json.loads(ops_json_str) | |
except json.JSONDecodeError as e: | |
error_char_pos = e.pos | |
context_window = 40 | |
start_context = max(0, error_char_pos - context_window) | |
end_context = min(len(ops_json_str), error_char_pos + context_window) | |
problem_context = ops_json_str[start_context:end_context].replace("\n", "\\n") # Show newlines escaped in log | |
logger.warning( | |
f"DEFERRED [{task_id}]: Initial JSON ops parse error: {e}. " | |
f"Error at char {error_char_pos}. Context around error: '...{problem_context}...'. " | |
f"Attempting to repair newlines in the JSON string." | |
) | |
# logger.debug(f"DEFERRED [{task_id}]: Problematic JSON string before repair:\n>>>>>>>>>>\n{ops_json_str}\n<<<<<<<<<<") | |
repaired_json_str = repair_json_string_newslines(ops_json_str) | |
# logger.debug(f"DEFERRED [{task_id}]: JSON string after repair attempt:\n>>>>>>>>>>\n{repaired_json_str}\n<<<<<<<<<<") | |
try: | |
ops = json.loads(repaired_json_str) | |
logger.info(f"DEFERRED [{task_id}]: JSON successfully parsed after repair attempt.") | |
except json.JSONDecodeError as e2: | |
logger.error( | |
f"DEFERRED [{task_id}]: JSON ops parse error EVEN AFTER REPAIR: {e2}. " | |
f"The repair attempt was not sufficient. Skipping insight operations for this turn." | |
) | |
# Log the string that failed after repair for further debugging | |
# logger.error(f"DEFERRED [{task_id}]: Repaired JSON string that still failed:\n>>>>>>>>>>\n{repaired_json_str}\n<<<<<<<<<<") | |
ops = [] | |
else: | |
logger.info(f"DEFERRED [{task_id}]: No JSON list structure (e.g., starting with '[') found in LLM output. Full raw output:\n>>>>>>>>>>\n{raw_ops_json_full}\n<<<<<<<<<<") | |
if isinstance(ops, list) and ops: | |
logger.info(f"DEFERRED [{task_id}]: LLM provided {len(ops)} insight ops to process.") | |
for op_idx, op in enumerate(ops): | |
if not isinstance(op, dict): | |
logger.warning(f"DEFERRED [{task_id}]: Op {op_idx}: Skipped non-dict item in ops list: {op}") | |
continue | |
action = op.get("action","").lower() | |
insight_text_raw = op.get("insight") | |
old_insight_raw = op.get("old_insight_to_replace") | |
if not isinstance(insight_text_raw, str): | |
logger.warning(f"DEFERRED [{task_id}]: Op {op_idx}: Skipped op due to non-string insight_text (type: {type(insight_text_raw)}): {op}") | |
continue | |
insight_text = insight_text_raw.strip() | |
old_insight = None | |
if old_insight_raw is not None: | |
if not isinstance(old_insight_raw, str): | |
logger.warning(f"DEFERRED [{task_id}]: Op {op_idx}: Skipped op due to non-string/non-null old_insight_to_replace (type: {type(old_insight_raw)}): {op}") | |
continue | |
old_insight = old_insight_raw.strip() | |
if not insight_text or not re.match(r"\[(CORE_RULE|RESPONSE_PRINCIPLE|BEHAVIORAL_ADJUSTMENT|GENERAL_LEARNING)\|([\d\.]+?)\]", insight_text, re.I|re.DOTALL): | |
logger.warning(f"DEFERRED [{task_id}]: Op {op_idx}: Skipped op due to invalid/empty insight_text format: '{insight_text[:100]}...' from op: {op}") | |
continue | |
if action == "add": | |
success, status_msg = add_rule_entry(insight_text) | |
if success: processed_count +=1 | |
else: logger.warning(f"DEFERRED [{task_id}]: Op {op_idx} (add): Failed to add rule '{insight_text[:50]}...'. Status: {status_msg}") | |
elif action == "update": | |
if old_insight: | |
if old_insight != insight_text: | |
remove_success = remove_rule_entry(old_insight) | |
if not remove_success: | |
logger.warning(f"DEFERRED [{task_id}]: Op {op_idx} (update): Failed to remove old rule '{old_insight[:50]}...' before adding new. This might lead to duplicates if the new rule is different.") | |
else: | |
logger.info(f"DEFERRED [{task_id}]: Op {op_idx} (update): Old insight is identical to new insight. Skipping removal and effectively treating as 'add if not present'.") | |
success, status_msg = add_rule_entry(insight_text) | |
if success: processed_count +=1 | |
else: logger.warning(f"DEFERRED [{task_id}]: Op {op_idx} (update): Failed to add/update rule '{insight_text[:50]}...'. Status: {status_msg}") | |
else: | |
logger.warning(f"DEFERRED [{task_id}]: Op {op_idx}: Skipped op due to unknown action '{action}': {op}") | |
logger.info(f"DEFERRED [{task_id}]: Processed {processed_count} insight ops out of {len(ops)} received.") | |
elif not json_match_ops : | |
pass | |
else: | |
logger.info(f"DEFERRED [{task_id}]: No valid list of insight ops from LLM after parsing was attempted. Raw match (ops_json_str - first 500 chars):\n>>>>>>>>>>\n{ops_json_str[:500] if ops_json_str else 'N/A'}\n<<<<<<<<<<") | |
except Exception as e: logger.error(f"DEFERRED [{task_id}]: CRITICAL ERROR in deferred task: {e}", exc_info=True) | |
logger.info(f"DEFERRED [{task_id}]: END. Total: {time.time() - start_time:.2f}s") | |
def handle_gradio_chat_submit(user_msg_txt: str, gr_hist_list: list, sel_prov_name: str, sel_model_disp_name: str, ui_api_key: str|None, cust_sys_prompt: str): | |
global current_chat_session_history | |
cleared_input, updated_gr_hist, status_txt = "", list(gr_hist_list), "Initializing..." | |
def_detect_out_md = gr.Markdown(visible=False) | |
def_fmt_out_txt = gr.Textbox(value="*Waiting...*", interactive=True) | |
def_dl_btn = gr.DownloadButton(interactive=False, value=None, visible=False) | |
if not user_msg_txt.strip(): | |
status_txt = "Error: Empty message." | |
updated_gr_hist.append((user_msg_txt or "(Empty)", status_txt)) | |
yield (cleared_input, updated_gr_hist, status_txt, def_detect_out_md, def_fmt_out_txt, def_dl_btn) | |
return | |
updated_gr_hist.append((user_msg_txt, "<i>Thinking...</i>")) | |
yield (cleared_input, updated_gr_hist, status_txt, def_detect_out_md, def_fmt_out_txt, def_dl_btn) | |
internal_hist = list(current_chat_session_history); internal_hist.append({"role": "user", "content": user_msg_txt}) | |
if len(internal_hist) > (MAX_HISTORY_TURNS * 2 + 1): | |
if internal_hist[0]["role"] == "system" and len(internal_hist) > (MAX_HISTORY_TURNS * 2 + 1) : internal_hist = [internal_hist[0]] + internal_hist[-(MAX_HISTORY_TURNS * 2):] | |
else: internal_hist = internal_hist[-(MAX_HISTORY_TURNS * 2):] | |
final_bot_resp_acc, insights_used_parsed = "", [] | |
temp_dl_file_path = None | |
try: | |
processor_gen = process_user_interaction_gradio(user_input=user_msg_txt, provider_name=sel_prov_name, model_display_name=sel_model_disp_name, chat_history_for_prompt=internal_hist, custom_system_prompt=cust_sys_prompt.strip() or None, ui_api_key_override=ui_api_key.strip() if ui_api_key else None) | |
curr_bot_disp_msg = "" | |
for upd_type, upd_data in processor_gen: | |
if upd_type == "status": | |
status_txt = upd_data | |
if updated_gr_hist and updated_gr_hist[-1][0] == user_msg_txt: | |
updated_gr_hist[-1] = (user_msg_txt, f"{curr_bot_disp_msg} <i>{status_txt}</i>" if curr_bot_disp_msg else f"<i>{status_txt}</i>") | |
elif upd_type == "response_chunk": | |
curr_bot_disp_msg += upd_data | |
if updated_gr_hist and updated_gr_hist[-1][0] == user_msg_txt: | |
updated_gr_hist[-1] = (user_msg_txt, curr_bot_disp_msg) | |
elif upd_type == "final_response_and_insights": | |
final_bot_resp_acc, insights_used_parsed = upd_data["response"], upd_data["insights_used"] | |
status_txt = "Response complete." | |
if not curr_bot_disp_msg and final_bot_resp_acc : curr_bot_disp_msg = final_bot_resp_acc | |
if updated_gr_hist and updated_gr_hist[-1][0] == user_msg_txt: | |
updated_gr_hist[-1] = (user_msg_txt, curr_bot_disp_msg or "(No text)") | |
def_fmt_out_txt = gr.Textbox(value=curr_bot_disp_msg, interactive=True, show_copy_button=True) | |
if curr_bot_disp_msg and not curr_bot_disp_msg.startswith("Error:"): | |
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".md", encoding='utf-8') as tmpfile: | |
tmpfile.write(curr_bot_disp_msg) | |
temp_dl_file_path = tmpfile.name | |
def_dl_btn = gr.DownloadButton(value=temp_dl_file_path, visible=True, interactive=True) | |
else: | |
def_dl_btn = gr.DownloadButton(interactive=False, value=None, visible=False) | |
insights_md_content = "### Insights Considered:\n" + ("\n".join([f"- **[{i.get('type','N/A')}|{i.get('score','N/A')}]** {i.get('text','N/A')[:100]}..." for i in insights_used_parsed[:3]]) if insights_used_parsed else "*None specific.*") | |
def_detect_out_md = gr.Markdown(value=insights_md_content, visible=True if insights_used_parsed else False) | |
yield (cleared_input, updated_gr_hist, status_txt, def_detect_out_md, def_fmt_out_txt, def_dl_btn) | |
if upd_type == "final_response_and_insights": break | |
except Exception as e: | |
logger.error(f"Chat handler error: {e}", exc_info=True); status_txt = f"Error: {str(e)[:100]}" | |
error_message_for_chat = f"Sorry, an error occurred: {str(e)[:100]}" | |
if updated_gr_hist and updated_gr_hist[-1][0] == user_msg_txt: | |
updated_gr_hist[-1] = (user_msg_txt, error_message_for_chat) | |
else: | |
updated_gr_hist.append((user_msg_txt, error_message_for_chat)) | |
def_fmt_out_txt = gr.Textbox(value=error_message_for_chat, interactive=True) | |
def_dl_btn = gr.DownloadButton(interactive=False, value=None, visible=False) | |
def_detect_out_md = gr.Markdown(value="*Error processing request.*", visible=True) | |
yield (cleared_input, updated_gr_hist, status_txt, def_detect_out_md, def_fmt_out_txt, def_dl_btn) | |
return | |
if final_bot_resp_acc and not final_bot_resp_acc.startswith("Error:"): | |
current_chat_session_history.extend([{"role": "user", "content": user_msg_txt}, {"role": "assistant", "content": final_bot_resp_acc}]) | |
hist_len_check = MAX_HISTORY_TURNS * 2 | |
if current_chat_session_history and current_chat_session_history[0]["role"] == "system": hist_len_check +=1 | |
if len(current_chat_session_history) > hist_len_check: | |
current_chat_session_history = ([current_chat_session_history[0]] if current_chat_session_history[0]["role"] == "system" else []) + current_chat_session_history[-(MAX_HISTORY_TURNS * 2):] | |
threading.Thread(target=deferred_learning_and_memory_task, args=(user_msg_txt, final_bot_resp_acc, sel_prov_name, sel_model_disp_name, insights_used_parsed, ui_api_key.strip() if ui_api_key else None), daemon=True).start() | |
status_txt = "Response complete. Background learning initiated." | |
else: | |
status_txt = "Processing finished; no valid response or error occurred." | |
if final_bot_resp_acc.startswith("Error:"): | |
status_txt = final_bot_resp_acc | |
if updated_gr_hist and updated_gr_hist[-1][0] == user_msg_txt: | |
updated_gr_hist[-1] = (user_msg_txt, final_bot_resp_acc) | |
def_fmt_out_txt = gr.Textbox(value=final_bot_resp_acc, interactive=True) | |
def_dl_btn = gr.DownloadButton(interactive=False, value=None, visible=False) | |
yield (cleared_input, updated_gr_hist, status_txt, def_detect_out_md, def_fmt_out_txt, def_dl_btn) | |
if temp_dl_file_path and os.path.exists(temp_dl_file_path): | |
try: os.unlink(temp_dl_file_path) | |
except Exception as e_unlink: logger.error(f"Error deleting temp download file {temp_dl_file_path}: {e_unlink}") | |
# --- UI Functions for Rules and Memories --- | |
def ui_refresh_rules_display_fn(): return "\n\n---\n\n".join(get_all_rules_cached()) or "No rules found." | |
def ui_download_rules_action_fn(): | |
rules_content = "\n\n---\n\n".join(get_all_rules_cached()) | |
if not rules_content.strip(): | |
gr.Warning("No rules to download.") | |
return gr.DownloadButton(value=None, interactive=False, label="No Rules") | |
try: | |
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt", encoding='utf-8') as tmpfile: | |
tmpfile.write(rules_content) | |
return tmpfile.name | |
except Exception as e: | |
logger.error(f"Error creating rules download file: {e}") | |
gr.Error(f"Failed to prepare rules for download: {e}") | |
return gr.DownloadButton(value=None, interactive=False, label="Error") | |
def ui_upload_rules_action_fn(uploaded_file_obj, progress=gr.Progress()): | |
if not uploaded_file_obj: return "No file provided for rules upload." | |
try: | |
with open(uploaded_file_obj.name, 'r', encoding='utf-8') as f: content = f.read() | |
except Exception as e_read: return f"Error reading file: {e_read}" | |
if not content.strip(): return "Uploaded rules file is empty." | |
added_count, skipped_count, error_count = 0,0,0 | |
# For .txt, split by '---' | |
if uploaded_file_obj.name.lower().endswith(".txt"): | |
potential_rules = content.split("\n\n---\n\n") | |
if len(potential_rules) == 1 and "\n" in content: # Fallback for simple newline separation in .txt | |
potential_rules = [r.strip() for r in content.splitlines() if r.strip()] | |
elif uploaded_file_obj.name.lower().endswith(".jsonl"): | |
potential_rules = [] | |
for line in content.splitlines(): | |
if line.strip(): | |
try: | |
# Expect each line to be a JSON string containing the rule text | |
rule_text_in_json_string = json.loads(line) | |
if isinstance(rule_text_in_json_string, str): | |
potential_rules.append(rule_text_in_json_string) | |
else: | |
logger.warning(f"Rule Upload: Skipped non-string rule from JSONL: {rule_text_in_json_string}") | |
error_count +=1 | |
except json.JSONDecodeError: | |
logger.warning(f"Rule Upload: Failed to parse JSONL line for rule: {line}") | |
error_count +=1 | |
else: | |
return "Unsupported file type for rules. Please use .txt or .jsonl." | |
total_to_process = len(potential_rules) | |
if total_to_process == 0 and error_count == 0: return "No rules found in file to process." | |
progress(0, desc="Starting rules upload...") | |
for idx, rule_text in enumerate(potential_rules): | |
rule_text = rule_text.strip() | |
if not rule_text: continue | |
success, status_msg = add_rule_entry(rule_text) | |
if success: added_count += 1 | |
elif status_msg == "duplicate": skipped_count += 1 | |
else: error_count += 1 # Increment error count for add_rule_entry failures too | |
progress((idx + 1) / total_to_process if total_to_process > 0 else 1, desc=f"Processed {idx+1}/{total_to_process} rules...") | |
msg = f"Rules Upload: Total lines/segments processed: {total_to_process}. Added: {added_count}, Skipped (duplicates): {skipped_count}, Errors/Invalid: {error_count}." | |
logger.info(msg); return msg | |
def ui_refresh_memories_display_fn(): return get_all_memories_cached() or [] | |
def ui_download_memories_action_fn(): | |
memories = get_all_memories_cached() | |
if not memories: | |
gr.Warning("No memories to download.") | |
return gr.DownloadButton(value=None, interactive=False, label="No Memories") | |
jsonl_content = "" | |
for mem_dict in memories: | |
try: jsonl_content += json.dumps(mem_dict) + "\n" | |
except Exception as e: logger.error(f"Error serializing memory for download: {mem_dict}, Error: {e}") | |
if not jsonl_content.strip(): | |
gr.Warning("No valid memories to serialize for download.") | |
return gr.DownloadButton(value=None, interactive=False, label="No Data") | |
try: | |
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".jsonl", encoding='utf-8') as tmpfile: | |
tmpfile.write(jsonl_content) | |
return tmpfile.name | |
except Exception as e: | |
logger.error(f"Error creating memories download file: {e}") | |
gr.Error(f"Failed to prepare memories for download: {e}") | |
return gr.DownloadButton(value=None, interactive=False, label="Error") | |
def ui_upload_memories_action_fn(uploaded_file_obj, progress=gr.Progress()): | |
if not uploaded_file_obj: return "No file provided for memories upload." | |
try: | |
with open(uploaded_file_obj.name, 'r', encoding='utf-8') as f: content = f.read() | |
except Exception as e_read: return f"Error reading file: {e_read}" | |
if not content.strip(): return "Uploaded memories file is empty." | |
added_count, format_error_count, save_error_count = 0,0,0 | |
memory_objects_to_process = [] | |
file_ext = os.path.splitext(uploaded_file_obj.name.lower())[1] | |
if file_ext == ".json": | |
try: | |
parsed_json = json.loads(content) | |
if isinstance(parsed_json, list): | |
memory_objects_to_process = parsed_json | |
elif isinstance(parsed_json, dict): # Single object | |
memory_objects_to_process = [parsed_json] | |
else: | |
format_error_count = 1 # Not a list or object | |
except json.JSONDecodeError: | |
format_error_count = 1 # Invalid JSON | |
elif file_ext == ".jsonl": | |
for line_num, line in enumerate(content.splitlines()): | |
if line.strip(): | |
try: | |
memory_objects_to_process.append(json.loads(line)) | |
except json.JSONDecodeError: | |
logger.warning(f"Memories Upload: JSONL line {line_num+1} parse error: {line[:100]}") | |
format_error_count += 1 | |
else: | |
return "Unsupported file type for memories. Please use .json or .jsonl." | |
if not memory_objects_to_process and format_error_count > 0 : | |
return f"Memories Upload: File parsing failed. Found {format_error_count} format errors." | |
elif not memory_objects_to_process: | |
return "No valid memory objects found in the uploaded file." | |
total_to_process = len(memory_objects_to_process) | |
if total_to_process == 0: return "No memory objects to process (after parsing)." | |
progress(0, desc="Starting memories upload...") | |
for idx, mem_data in enumerate(memory_objects_to_process): | |
if isinstance(mem_data, dict) and all(k in mem_data for k in ["user_input", "bot_response", "metrics"]): | |
success, _ = add_memory_entry(mem_data["user_input"], mem_data["metrics"], mem_data["bot_response"]) | |
if success: added_count += 1 | |
else: save_error_count += 1 | |
else: | |
logger.warning(f"Memories Upload: Skipped invalid memory object structure: {str(mem_data)[:100]}") | |
format_error_count += 1 | |
progress((idx + 1) / total_to_process, desc=f"Processed {idx+1}/{total_to_process} memories...") | |
msg = f"Memories Upload: Processed {total_to_process} objects. Added: {added_count}, Format/Structure Errors: {format_error_count}, Save Errors: {save_error_count}." | |
logger.info(msg); return msg | |
with gr.Blocks( | |
theme=gr.themes.Soft(), | |
css=""" | |
.gr-button { margin: 5px; } | |
.gr-textbox, .gr-text-area, .gr-dropdown { border-radius: 8px; } | |
.gr-group { border: 1px solid #e0e0e0; border-radius: 8px; padding: 10px; } | |
.gr-row { gap: 10px; } | |
.gr-tab { border-radius: 8px; } | |
.status-text { font-size: 0.9em; color: #555; } | |
""" | |
) as demo: | |
gr.Markdown( | |
""" | |
# 🤖 AI Research Agent | |
Your intelligent assistant for research and knowledge management | |
""", | |
elem_classes=["header"] | |
) | |
is_sqlite = MEMORY_STORAGE_BACKEND == "SQLITE" | |
is_hf_dataset = MEMORY_STORAGE_BACKEND == "HF_DATASET" | |
with gr.Row(variant="compact"): | |
agent_stat_tb = gr.Textbox( | |
label="Agent Status", value="Initializing systems...", interactive=False, | |
elem_classes=["status-text"], scale=4 | |
) | |
with gr.Column(scale=1, min_width=150): | |
memory_backend_info_tb = gr.Textbox( | |
label="Memory Backend", value=MEMORY_STORAGE_BACKEND, interactive=False, | |
elem_classes=["status-text"] | |
) | |
sqlite_path_display = gr.Textbox( | |
label="SQLite Path", value=MEMORY_SQLITE_PATH, interactive=False, | |
visible=is_sqlite, elem_classes=["status-text"] | |
) | |
hf_repos_display = gr.Textbox( | |
label="HF Repos", value=f"M: {MEMORY_HF_MEM_REPO}, R: {MEMORY_HF_RULES_REPO}", | |
interactive=False, visible=is_hf_dataset, elem_classes=["status-text"] | |
) | |
with gr.Row(): | |
with gr.Sidebar(): | |
gr.Markdown("## ⚙️ Configuration") | |
with gr.Group(): | |
gr.Markdown("### AI Model Settings") | |
api_key_tb = gr.Textbox( | |
label="AI Provider API Key (Override)", type="password", placeholder="Uses .env if blank" | |
) | |
prov_sel_dd = gr.Dropdown( | |
label="AI Provider", choices=get_available_providers(), | |
value=get_available_providers()[0] if get_available_providers() else None, interactive=True | |
) | |
model_sel_dd = gr.Dropdown( | |
label="AI Model", | |
choices=get_model_display_names_for_provider(get_available_providers()[0]) if get_available_providers() else [], | |
value=get_default_model_display_name_for_provider(get_available_providers()[0]) if get_available_providers() else None, | |
interactive=True | |
) | |
with gr.Group(): | |
gr.Markdown("### System Prompt") | |
sys_prompt_tb = gr.Textbox( | |
label="System Prompt Base", lines=8, value=DEFAULT_SYSTEM_PROMPT, interactive=True | |
) | |
if MEMORY_STORAGE_BACKEND == "RAM": | |
save_faiss_sidebar_btn = gr.Button("Save FAISS Indices", variant="secondary") | |
with gr.Column(scale=3): | |
with gr.Tabs(): | |
with gr.TabItem("💬 Chat & Research"): | |
with gr.Group(): | |
gr.Markdown("### AI Chat Interface") | |
main_chat_disp = gr.Chatbot( | |
label=None, height=400, bubble_full_width=False, | |
avatar_images=(None, "https://raw.githubusercontent.com/huggingface/brand-assets/main/hf-logo-with-title.png"), | |
show_copy_button=True, render_markdown=True, sanitize_html=True | |
) | |
with gr.Row(variant="compact"): | |
user_msg_tb = gr.Textbox( | |
show_label=False, placeholder="Ask your research question...", | |
scale=7, lines=1, max_lines=3 | |
) | |
send_btn = gr.Button("Send", variant="primary", scale=1, min_width=100) | |
with gr.Accordion("📝 Detailed Response & Insights", open=False): | |
fmt_report_tb = gr.Textbox( | |
label="Full AI Response", lines=8, interactive=True, show_copy_button=True | |
) | |
dl_report_btn = gr.DownloadButton( | |
"Download Report", value=None, interactive=False, visible=False | |
) | |
detect_out_md = gr.Markdown(visible=False) | |
with gr.TabItem("🧠 Knowledge Base"): | |
with gr.Row(equal_height=True): | |
with gr.Column(): | |
gr.Markdown("### 📜 Rules Management") | |
rules_disp_ta = gr.TextArea( | |
label="Current Rules (Read-only, Edit via Upload/Save)", lines=10, | |
placeholder="Rules will appear here. Use 'Save Edited Text' or 'Upload File' to modify.", | |
interactive=True | |
) | |
gr.Markdown("To edit rules, modify the text above and click 'Save Edited Text', or upload a new file.") | |
save_edited_rules_btn = gr.Button("💾 Save Edited Text", variant="primary") | |
with gr.Row(variant="compact"): | |
dl_rules_btn = gr.DownloadButton("⬇️ Download Rules", value=None) | |
clear_rules_btn = gr.Button("🗑️ Clear All Rules", variant="stop") | |
upload_rules_fobj = gr.File( | |
label="Upload Rules File (.txt with '---' separators, or .jsonl of rule strings)", | |
file_types=[".txt", ".jsonl"] | |
) | |
rules_stat_tb = gr.Textbox( | |
label="Rules Status", interactive=False, lines=1, elem_classes=["status-text"] | |
) | |
with gr.Column(): | |
gr.Markdown("### 📚 Memories Management") | |
mems_disp_json = gr.JSON( | |
label="Current Memories (Read-only)", value=[] | |
) | |
gr.Markdown("To add memories, upload a .jsonl or .json file.") | |
with gr.Row(variant="compact"): | |
dl_mems_btn = gr.DownloadButton("⬇️ Download Memories", value=None) | |
clear_mems_btn = gr.Button("🗑️ Clear All Memories", variant="stop") | |
upload_mems_fobj = gr.File( | |
label="Upload Memories File (.jsonl of memory objects, or .json array of objects)", | |
file_types=[".jsonl", ".json"] | |
) | |
mems_stat_tb = gr.Textbox( | |
label="Memories Status", interactive=False, lines=1, elem_classes=["status-text"] | |
) | |
def dyn_upd_model_dd(sel_prov_dyn: str): | |
models_dyn = get_model_display_names_for_provider(sel_prov_dyn) | |
def_model_dyn = get_default_model_display_name_for_provider(sel_prov_dyn) | |
return gr.Dropdown(choices=models_dyn, value=def_model_dyn, interactive=True) | |
prov_sel_dd.change(fn=dyn_upd_model_dd, inputs=prov_sel_dd, outputs=model_sel_dd) | |
chat_ins = [user_msg_tb, main_chat_disp, prov_sel_dd, model_sel_dd, api_key_tb, sys_prompt_tb] | |
chat_outs = [user_msg_tb, main_chat_disp, agent_stat_tb, detect_out_md, fmt_report_tb, dl_report_btn] | |
chat_event_args = {"fn": handle_gradio_chat_submit, "inputs": chat_ins, "outputs": chat_outs} | |
send_btn_click_event = send_btn.click(**chat_event_args) | |
user_msg_submit_event = user_msg_tb.submit(**chat_event_args) | |
for event in [send_btn_click_event, user_msg_submit_event]: | |
event.then(fn=ui_refresh_rules_display_fn, inputs=None, outputs=rules_disp_ta, show_progress=False) | |
event.then(fn=ui_refresh_memories_display_fn, inputs=None, outputs=mems_disp_json, show_progress=False) | |
# Rules Management events | |
dl_rules_btn.click(fn=ui_download_rules_action_fn, inputs=None, outputs=dl_rules_btn) | |
def save_edited_rules_action_fn(edited_rules_text: str, progress=gr.Progress()): | |
if not edited_rules_text.strip(): | |
return "No rules text to save." | |
potential_rules = edited_rules_text.split("\n\n---\n\n") | |
if len(potential_rules) == 1 and "\n" in edited_rules_text: | |
potential_rules = [r.strip() for r in edited_rules_text.splitlines() if r.strip()] | |
if not potential_rules: | |
return "No rules found to process from editor." | |
added, skipped, errors = 0, 0, 0 | |
unique_rules_to_process = sorted(list(set(filter(None, [r.strip() for r in potential_rules])))) | |
total_unique = len(unique_rules_to_process) | |
if total_unique == 0: return "No unique, non-empty rules found in editor text." | |
progress(0, desc=f"Saving {total_unique} unique rules from editor...") | |
for idx, rule_text in enumerate(unique_rules_to_process): | |
success, status_msg = add_rule_entry(rule_text) | |
if success: added += 1 | |
elif status_msg == "duplicate": skipped += 1 | |
else: errors += 1 | |
progress((idx + 1) / total_unique, desc=f"Processed {idx+1}/{total_unique} rules...") | |
return f"Editor Save: Added: {added}, Skipped (duplicates): {skipped}, Errors/Invalid: {errors} from {total_unique} unique rules in text." | |
save_edited_rules_btn.click( | |
fn=save_edited_rules_action_fn, | |
inputs=[rules_disp_ta], | |
outputs=[rules_stat_tb], | |
show_progress="full" | |
).then(fn=ui_refresh_rules_display_fn, outputs=rules_disp_ta, show_progress=False) | |
upload_rules_fobj.upload( | |
fn=ui_upload_rules_action_fn, | |
inputs=[upload_rules_fobj], | |
outputs=[rules_stat_tb], | |
show_progress="full" | |
).then(fn=ui_refresh_rules_display_fn, outputs=rules_disp_ta, show_progress=False) | |
clear_rules_btn.click( | |
fn=lambda: ("All rules cleared." if clear_all_rules_data_backend() else "Error clearing rules."), | |
outputs=rules_stat_tb, | |
show_progress=False | |
).then(fn=ui_refresh_rules_display_fn, outputs=rules_disp_ta, show_progress=False) | |
# Memories Management events | |
dl_mems_btn.click(fn=ui_download_memories_action_fn, inputs=None, outputs=dl_mems_btn) | |
upload_mems_fobj.upload( | |
fn=ui_upload_memories_action_fn, | |
inputs=[upload_mems_fobj], | |
outputs=[mems_stat_tb], | |
show_progress="full" | |
).then(fn=ui_refresh_memories_display_fn, outputs=mems_disp_json, show_progress=False) | |
clear_mems_btn.click( | |
fn=lambda: ("All memories cleared." if clear_all_memory_data_backend() else "Error clearing memories."), | |
outputs=mems_stat_tb, | |
show_progress=False | |
).then(fn=ui_refresh_memories_display_fn, outputs=mems_disp_json, show_progress=False) | |
if MEMORY_STORAGE_BACKEND == "RAM" and 'save_faiss_sidebar_btn' in locals(): | |
def save_faiss_action_with_feedback_sidebar_fn(): | |
save_faiss_indices_to_disk() | |
gr.Info("Attempted to save FAISS indices to disk.") | |
save_faiss_sidebar_btn.click(fn=save_faiss_action_with_feedback_sidebar_fn, inputs=None, outputs=None) | |
def app_load_fn(): | |
initialize_memory_system() | |
logger.info("App loaded. Memory system initialized.") | |
backend_status = "AI Systems Initialized. Ready." | |
rules_on_load = ui_refresh_rules_display_fn() | |
mems_on_load = ui_refresh_memories_display_fn() | |
return ( | |
backend_status, | |
rules_on_load, | |
mems_on_load, | |
gr.Markdown(visible=False), | |
gr.Textbox(value="*Waiting...*", interactive=True), | |
gr.DownloadButton(interactive=False, value=None, visible=False) | |
) | |
initial_load_outputs = [ | |
agent_stat_tb, | |
rules_disp_ta, | |
mems_disp_json, | |
detect_out_md, | |
fmt_report_tb, | |
dl_report_btn | |
] | |
demo.load(fn=app_load_fn, inputs=None, outputs=initial_load_outputs) | |
if __name__ == "__main__": | |
logger.info(f"Starting Gradio AI Research Mega Agent (v6.1 - JSON Repair & UI Polish, Memory: {MEMORY_STORAGE_BACKEND})...") | |
app_port = int(os.getenv("GRADIO_PORT", 7860)) | |
app_server = os.getenv("GRADIO_SERVER_NAME", "127.0.0.1") | |
app_debug = os.getenv("GRADIO_DEBUG", "False").lower() == "true" | |
app_share = os.getenv("GRADIO_SHARE", "False").lower() == "true" | |
logger.info(f"Launching Gradio server: http://{app_server}:{app_port}. Debug: {app_debug}, Share: {app_share}") | |
demo.queue().launch(server_name=app_server, server_port=app_port, debug=app_debug, share=app_share) | |
logger.info("Gradio application shut down.") |