File size: 8,471 Bytes
65de2c3
 
 
 
3464008
65de2c3
 
 
 
 
cd0a2af
65de2c3
 
 
 
 
 
 
 
 
a3c34a3
65de2c3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a3c34a3
65de2c3
a3c34a3
 
65de2c3
 
a3c34a3
65de2c3
a3c34a3
 
65de2c3
 
a3c34a3
 
65de2c3
a3c34a3
 
65de2c3
 
 
 
 
 
 
 
 
 
 
 
cd0a2af
65de2c3
 
 
 
cd0a2af
 
 
a3c34a3
65de2c3
3373a9c
 
 
 
 
65de2c3
 
 
a3c34a3
65de2c3
 
 
 
 
 
 
 
a3c34a3
65de2c3
a3c34a3
 
 
 
 
 
 
 
65de2c3
 
ff0f2cc
a3c34a3
ff0f2cc
a3c34a3
ff0f2cc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65de2c3
ff0f2cc
 
65de2c3
a3c34a3
 
65de2c3
 
 
ff0f2cc
a3c34a3
 
3464008
 
 
 
 
 
ff0f2cc
 
65de2c3
 
a3c34a3
 
65de2c3
a3c34a3
65de2c3
 
 
 
 
a3c34a3
 
 
 
 
 
 
 
 
 
 
 
65de2c3
 
a3c34a3
 
65de2c3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
import asyncio
import os
from knowledge_base import KnowledgeBase
from scraper import search_and_extract
import worldmonitor_client
from errors import get_logger, GenerAIError, ErrorCode, fmt_exc

log = get_logger("brain")

LOCAL_THRESHOLD = 0.40
HF_MODEL = os.environ.get("HF_MODEL", "amogaddy/GenerAI")
HF_TOKEN = os.environ.get("HF_TOKEN", "")


def _rerank(results: list, query: str) -> list:
    words = set(query.lower().split())
    for r in results:
        stored_q = r["metadata"].get("query", "")
        overlap = len(words & set(stored_q.lower().split()))
        r["score"] = r["distance"] - overlap * 0.12
    return sorted(results, key=lambda x: x["score"])


def _extract_sentences(text: str, query: str, max_chars: int = 700) -> str:
    words = set(query.lower().split())
    sentences = [s.strip() for s in text.replace("\n", ". ").split(".") if len(s.strip()) > 20]
    scored = [(sum(1 for w in words if w in s.lower()), s) for s in sentences]
    scored.sort(key=lambda x: -x[0])
    result = ""
    for _, s in scored:
        if len(result) + len(s) + 2 > max_chars:
            break
        result += s + ". "
    return result.strip() or text[:max_chars]


class _HFClient:
    SYSTEM = (
        "Sei GenerAI, un assistente AI specializzato in lingua italiana. "
        "Rispondi in modo chiaro, preciso e sempre in italiano. "
        "Se ti viene fornito del contesto, basati su quello per rispondere."
    )

    def __init__(self, model: str, token: str):
        try:
            from huggingface_hub import InferenceClient
            self._client = InferenceClient(model=model, token=token or None)
            self._model = model
            log.info("HuggingFace LLM pronto: %s", model)
        except ImportError:
            raise GenerAIError(ErrorCode.BRAIN_ASK_FAILED, "huggingface_hub non installato.")
        except Exception as e:
            raise GenerAIError(ErrorCode.BRAIN_ASK_FAILED,
                               f"Impossibile connettersi a HF ({model}): {fmt_exc(e)}", cause=e)

    def generate(self, question: str, context: str = "") -> str:
        user_msg = f"Contesto:\n{context}\n\nDomanda: {question}" if context else question
        messages = [
            {"role": "system", "content": self.SYSTEM},
            {"role": "user",   "content": user_msg},
        ]
        try:
            r = self._client.chat_completion(messages=messages, max_tokens=512, temperature=0.3)
            return r.choices[0].message.content.strip()
        except Exception as e:
            raise GenerAIError(ErrorCode.BRAIN_ASK_FAILED,
                               f"Generazione HF fallita: {fmt_exc(e)}", cause=e)


class Brain:
    def __init__(self):
        log.info("Avvio Brain...")
        try:
            self.kb = KnowledgeBase()
        except GenerAIError as e:
            e.log(log)
            raise
        self._last_doc_id: str | None = None
        self._hf: _HFClient | None = None
        if HF_TOKEN:
            try:
                self._hf = _HFClient(HF_MODEL, HF_TOKEN)
            except GenerAIError as e:
                e.log(log)
                log.warning("Fallback a ricerca web + estrazione (HF non disponibile).")
        else:
            log.info("HF_TOKEN non impostato — uso ricerca web + estrazione testo.")
        log.info("Brain pronto. Modalita: %s", "LLM+KB" if self._hf else "KB+Web")

    async def ask(self, question: str, on_status=None) -> tuple[str, str]:
        async def emit(msg: str):
            if on_status:
                await on_status(msg)

        self._last_doc_id = None
        log.info("Domanda: %r", question)

        await emit("Cerco nella memoria locale...")
        try:
            results = _rerank(self.kb.search(question, n_results=12), question)
        except Exception as e:
            log.warning("KB search fallita: %s", fmt_exc(e))
            results = []

        if results and results[0]["score"] < LOCAL_THRESHOLD:
            best = results[0]
            await emit(f"Trovato in memoria locale (affidabilita: {int((1-results[0]['score'])*100)}%)")
            if self._hf:
                await emit("Elaboro risposta con intelligenza artificiale...")
                return await self._llm_answer(question, best["answer"],
                                              best["metadata"].get("source", ""), "llm")
            answer = _extract_sentences(best["answer"], question)
            src = best["metadata"].get("source", "")
            if src and src != "grammatica_italiana":
                answer += f"\n\n*Fonte: {src}*"
            await emit("Risposta pronta dalla memoria locale.")
            return answer, "local"

        await emit("Non trovato in memoria — consulto World Monitor...")
        try:
            wm_items = await asyncio.to_thread(worldmonitor_client.find_relevant, question, 5)
        except Exception as e:
            log.debug("World Monitor non consultabile: %s", fmt_exc(e))
            wm_items = []

        # World Monitor è il primo posto in cui si cerca: se ha risultati
        # pertinenti a sufficienza, si evita del tutto la ricerca web (più
        # veloce, nessun browser Chromium da avviare). Altrimenti si scende
        # sul web come prima, e gli item WM trovati (se pochi) restano come
        # contesto aggiuntivo.
        web_results = []
        if len(wm_items) >= 2:
            await emit(f"Trovati {len(wm_items)} risultati pertinenti su World Monitor.")
        else:
            await emit("World Monitor non basta — cerco anche sul web...")
            try:
                web_results = await asyncio.to_thread(search_and_extract, question)
            except Exception as e:
                log.warning("Ricerca fallita: %s", fmt_exc(e))
                web_results = []

        if not web_results and not wm_items:
            await emit("Nessuna informazione trovata.")
            return (
                "Non ho trovato informazioni su questo argomento. "
                "Prova a riformulare la domanda.",
                "unknown",
            )

        await emit(f"Trovati {len(web_results) + len(wm_items)} risultati — elaboro...")
        await asyncio.sleep(0.2)

        blocks = [f"[{r['title']}]\n{r['text']}" for r in web_results]
        if wm_items:
            wm_block = "\n".join(f"- {it['title']} ({it['source']})" for it in wm_items)
            blocks.insert(0, f"[World Monitor — notizie recenti]\n{wm_block}")

        combined = "\n\n---\n\n".join(blocks)
        sources = ", ".join(r["url"] for r in web_results if r.get("url")) \
            or ", ".join(it["url"] for it in wm_items if it.get("url"))

        if self._hf:
            await emit("Sintetizzo con intelligenza artificiale...")
            answer, status = await self._llm_answer(question, combined, sources, "llm")
        else:
            await emit("Estraggo i passaggi significativi...")
            answer = _extract_sentences(combined, question)
            if sources:
                answer += f"\n\n*Fonte: {sources}*"
            status = "searched"

        # Auto-apprendimento: salva in KB se domanda frequente
        learned = False
        try:
            if self.kb.increment_and_should_archive(question):
                doc_id = self.kb.add(question, answer, sources)
                self._last_doc_id = doc_id
                learned = True
                await emit("Concetto appreso e salvato.")
        except Exception as e:
            log.warning("Auto-apprendimento fallito: %s", fmt_exc(e))

        await emit("Risposta pronta!")
        return answer, status

    async def _llm_answer(self, question: str, context: str,
                          source: str, status: str) -> tuple[str, str]:
        try:
            answer = await asyncio.to_thread(self._hf.generate, question, context)
            if source and source != "grammatica_italiana":
                answer += f"\n\n*Fonte: {source}*"
            return answer, status
        except GenerAIError as e:
            e.log(log)
            answer = _extract_sentences(context, question)
            if source and source != "grammatica_italiana":
                answer += f"\n\n*Fonte: {source}*"
            return answer, "searched"

    def give_feedback(self, positive: bool):
        if self._last_doc_id:
            self.kb.reinforce(self._last_doc_id, positive)

    @property
    def kb_size(self) -> int:
        return self.kb.count()