File size: 4,785 Bytes
2d3a0d2
954e0aa
373441d
709ae5b
 
d2ce5f7
709ae5b
 
 
 
962a395
 
 
ac347e7
962a395
 
 
2d3a0d2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
02eef30
 
 
 
 
 
 
 
 
 
 
962a395
38fc3eb
02eef30
25c8a13
 
38fc3eb
25c8a13
38fc3eb
 
25c8a13
38fc3eb
25c8a13
 
 
 
 
 
 
 
 
a82568e
 
6c898a6
 
373441d
 
 
 
 
6c898a6
 
 
 
 
373441d
 
 
 
 
 
 
 
 
6c898a6
373441d
 
 
 
6c898a6
 
a82568e
6c898a6
 
 
 
 
 
 
 
 
 
 
d2ce5f7
6c898a6
d2ce5f7
25c8a13
d2ce5f7
 
25c8a13
d2ce5f7
25c8a13
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
import re
from loguru import logger
from src.rag.rag import get_summary_llm, get_summary_fallback_llm, _agentic_gather_web_content
from .constants import (
    SUMMARIZER_PROMPT_TEMPLATE,
    WEB_SUMMARIZER_PROMPT_TEMPLATE,
    MAX_INPUT_CHARS,
    WIKI_TOP_K_RESULTS,
    WIKI_DOC_CONTENT_CHARS_MAX,
)


def summarizer_prompt():
    return SUMMARIZER_PROMPT_TEMPLATE



def clean_summary(text: str) -> str:
    lines = text.split('\n')
    cleaned = []
    for line in lines:
        stripped = line.strip()
        # Remove markdown table separator lines (e.g. |---|---|)
        if re.match(r'^[\s\|]*[-]{2,}[\s\|]*$', stripped):
            continue
        # If it's a table row, just keep it but maybe clean it up a bit
        if re.match(r'^\|.*\|$', stripped):
            parts = [p.strip() for p in stripped.split('|')]
            parts = [p for p in parts if p]
            line = ' | '.join(parts)
        # Note: We NO LONGER strip bold (**) here because the frontend uses it for styling
        cleaned.append(line)
    return '\n'.join(cleaned)


def _truncate_text(text: str, max_chars: int = MAX_INPUT_CHARS) -> str:
    if len(text) <= max_chars:
        return text
    first_len = int(max_chars * 0.6)
    last_len = max_chars - first_len
    first_part = text[:first_len]
    last_part = text[-last_len:]
    logger.info(f"Text truncated from {len(text)} to {max_chars} chars")
    return f"{first_part}\n\n...[content truncated]...\n\n{last_part}"


def summarizer(text: str) -> str:
    logger.info(f"Summarizer started for text of length {len(text)}")
    text = _truncate_text(text)
    prompt = summarizer_prompt()

    try:
        llm = get_summary_llm()
        chain = prompt | llm
        response = chain.invoke({"input": text})
        return clean_summary(response.content)
    except Exception as e:
        logger.warning(f"Primary summary model (gemini-3.5-flash-lite) failed: {e}. Falling back to gemini-3.1-flash-lite.")
        try:
            fallback_llm = get_summary_fallback_llm()
            chain = prompt | fallback_llm
            response = chain.invoke({"input": text})
            return clean_summary(response.content)
        except Exception as fallback_err:
            logger.error(f"Fallback summary generation failed: {fallback_err}", exc_info=True)
            raise fallback_err


def fetch_web_content(topic: str) -> str:
    """
    Fetch web content for *topic* using the agentic tool-selection engine.

    The router LLM decides which combination of Wikipedia, DuckDuckGo, and
    ArXiv will produce the richest educational content for this topic, then
    executes the selected tools in parallel and returns the combined text.

    The returned text can be:
    - Chunked and stored in the DB for future reuse.
    - Passed directly to an LLM prompt as context.
    """
    logger.info(f"fetch_web_content (agentic) started for topic: {topic}")
    combined, has_wiki, has_ddg, has_arxiv = _agentic_gather_web_content(
        query=topic,
        is_topic=True,
        existing_doc_context="",
        subject_title="",
    )
    if not combined or not combined.strip():
        logger.warning(f"No web content found for topic: {topic}. Returning placeholder.")
        return f"No web content found for: {topic}"
    logger.info(
        f"fetch_web_content done for '{topic}': "
        f"wiki={has_wiki} ddg={has_ddg} arxiv={has_arxiv} chars={len(combined)}"
    )
    return combined


def web_summarizer(topic: str, raw_content: str | None = None) -> str:
    """
    Generate an LLM summary for *topic*.

    If *raw_content* is provided (pre-fetched web text) it is used directly,
    skipping the Wiki/DDG network calls.  Otherwise fetch_web_content() is
    called internally so this function stays usable standalone.
    """
    logger.info(f"Web summarizer started for topic: {topic}")

    combined = raw_content if raw_content is not None else fetch_web_content(topic)
    combined = _truncate_text(combined)

    try:
        llm = get_summary_llm()
        chain = WEB_SUMMARIZER_PROMPT_TEMPLATE | llm
        response = chain.invoke({"topic": topic, "input": combined})
        return clean_summary(response.content)
    except Exception as e:
        logger.warning(f"Primary web summarizer model (gemini-3.5-flash-lite) failed: {e}. Falling back to gemini-3.1-flash-lite.")
        try:
            fallback_llm = get_summary_fallback_llm()
            chain = WEB_SUMMARIZER_PROMPT_TEMPLATE | fallback_llm
            response = chain.invoke({"topic": topic, "input": combined})
            return clean_summary(response.content)
        except Exception as fallback_err:
            logger.error(f"Fallback web summarizer failed: {fallback_err}", exc_info=True)
            raise fallback_err