diff --git a/src/app/api/api_v1/endpoints/chat.py b/src/app/api/api_v1/endpoints/chat.py index c93441cf..3c6ea5a3 100644 --- a/src/app/api/api_v1/endpoints/chat.py +++ b/src/app/api/api_v1/endpoints/chat.py @@ -20,6 +20,7 @@ from src.app.models import chat as models from src.app.search.services.search import SearchService, get_search_service from src.app.services.data_collection import get_data_collection_service +from src.app.services.helpers import linkify_missing_citations from src.app.shared.domain.constants import subjects as subjectsDict from src.app.shared.domain.exceptions import ( EmptyQueryError, @@ -71,6 +72,7 @@ def get_params(body: models.Context) -> models.ContextOut: query=body.query, subject=body.subject, conversation_id=None, + lang=body.lang, ) @@ -154,7 +156,7 @@ async def q_and_a_new_questions( ): try: new_questions = await chatfactory.get_new_questions( - query=body.query, history=body.history + query=body.query, history=body.history, lang=body.lang ) return new_questions @@ -487,13 +489,17 @@ async def agent_response( sp=sp, ) - if isinstance(res["messages"][-2], ToolMessage): - docs = res["messages"][-2].artifact - else: - docs = None + all_docs = [] + for msg in res["messages"]: + if isinstance(msg, ToolMessage) and getattr(msg, "artifact", None): + all_docs.extend(msg.artifact) + docs = all_docs if all_docs else None + content = linkify_missing_citations( + cast(str, res["messages"][-1].content), docs or [] + ) agent_ans = { - "content": cast(str, res["messages"][-1].content), + "content": content, "docs": docs, "thread_id": thread_id, } @@ -503,7 +509,7 @@ async def agent_response( session_id=session_id, user_query=body.query, conversation_id=thread_id, - answer_content=res["messages"][-1].content, + answer_content=content, sources=docs, ) diff --git a/src/app/api/api_v1/endpoints/chat_utils.py b/src/app/api/api_v1/endpoints/chat_utils.py index f61a656c..85adbabd 100644 --- a/src/app/api/api_v1/endpoints/chat_utils.py +++ b/src/app/api/api_v1/endpoints/chat_utils.py @@ -11,6 +11,7 @@ from src.app.models import chat as models from src.app.search.services.search import SearchService +from src.app.services.helpers import linkify_missing_citations from src.app.utils.logger import logger as utils_logger logger = utils_logger(__name__) @@ -154,7 +155,7 @@ async def _stream_agent_response( thread_id: UUID, ) -> AsyncGenerator[str, None]: final_content = "" - docs = [] + docs = None has_streamed_content = False stream = _stream_agent_with_memory( @@ -178,6 +179,8 @@ async def _stream_agent_response( except Exception as e: logger.error("Error while yielding chunk: %s", e) + final_content = linkify_missing_citations(final_content, docs) + final_payload = _build_final_stream_payload( final_content=final_content, docs=docs, diff --git a/src/app/models/chat.py b/src/app/models/chat.py index 63d553d5..097cc18f 100644 --- a/src/app/models/chat.py +++ b/src/app/models/chat.py @@ -15,6 +15,7 @@ class Context(BaseModel): history: list[dict] | None = [] query: str | None = None subject: str | None = Field(None) + lang: str | None = None class ContextOut(BaseModel): @@ -23,6 +24,7 @@ class ContextOut(BaseModel): query: str subject: str | None = Field(None) conversation_id: uuid.UUID | None = Field(None) + lang: str | None = None class Role(Enum): diff --git a/src/app/search/api/router.py b/src/app/search/api/router.py index e54e0a09..eab9a2fe 100644 --- a/src/app/search/api/router.py +++ b/src/app/search/api/router.py @@ -148,7 +148,6 @@ async def search_all_slices_by_lang( sp: SearchService = Depends(get_search_service), ): try: - res = await sp.search_handler( qp=qp, method=SearchMethods.BY_SLICES, background_tasks=background_tasks ) diff --git a/src/app/services/agent.py b/src/app/services/agent.py index 5827e91f..b7a0d745 100644 --- a/src/app/services/agent.py +++ b/src/app/services/agent.py @@ -20,6 +20,14 @@ async def _get_resources_about_sustainability( ) -> Tuple[str, List[Document]]: """Core logic for getting relevant resources about sustainability from WeLearn database.""" + tool_called: list = config["configurable"].get("tool_called", [False]) + if tool_called[0]: + return ( + "Search has already been performed. Use the documents already retrieved to answer the question.", + [], + ) + tool_called[0] = True + qp = EnhancedSearchQuery( query=rag_query, sdg_filter=config["configurable"].get("sdg_filter"), diff --git a/src/app/services/helpers.py b/src/app/services/helpers.py index 4c691eeb..508c9964 100644 --- a/src/app/services/helpers.py +++ b/src/app/services/helpers.py @@ -1,5 +1,6 @@ +import re from functools import cache -from typing import Any, List, cast +from typing import Any, List, Optional, cast import json_repair import numpy @@ -139,6 +140,43 @@ def stringify_docs_content(docs: List[Any]) -> str: return documents.strip() +_CITATION_RE = re.compile(r'(?)\[Doc\s*(\d+)\]') + + +def linkify_missing_citations(text: str, docs: List[Any]) -> str: + """ + Wraps any bare `[Doc N]` marker in `text` with the `` + tag for document N, using the URL from `docs[N-1]`. Markers already wrapped in an + tag are left untouched. Safety net for when the LLM forgets to format a + citation as a link itself. + + Args: + text: The assembled answer text. + docs: The full list of retrieved documents, 1-indexed by citation number. + + Returns: + str: `text` with any missed citations auto-linked. Unresolvable markers + (out-of-range N, or no docs) are left as-is. + """ + if not text or not docs: + return text + + def _url_for(n: int) -> Optional[str]: + if not (1 <= n <= len(docs)): + return None + payload = normalize_payload(getattr(docs[n - 1], "payload", docs[n - 1])) + return str(payload.get("document_url", "")).strip() or None + + def _replace(match: "re.Match[str]") -> str: + n = int(match.group(1)) + url = _url_for(n) + if not url: + return match.group(0) + return f'[Doc {n}]' + + return _CITATION_RE.sub(_replace, text) + + def extract_json_from_response( response: str, ) -> JSONReturnType | tuple[JSONReturnType, list[dict[str, str]]] | str: diff --git a/src/app/services/prompts.py b/src/app/services/prompts.py index 93c0740d..ac12cf5c 100644 --- a/src/app/services/prompts.py +++ b/src/app/services/prompts.py @@ -1,239 +1,169 @@ -####################################################### -########### PROMPTS FOR THE SOURCED ANSWER ############ -####################################################### - -SYSTEM_PROMPT = """ -CONTEXT: You are an expert in sustainable development goals (SDGs). +######################################################## +# ACTIVE PROMPTS +######################################################## + +########################################################### +####### /qna/chat/agent — main agent endpoint ############# +########################################################### + +AGENT_SYSTEM_PROMPT = """You are WeLearn's AI assistant, specialising in sustainable development goals (SDGs) and sustainability. Your users include students, educators, researchers, and NGO staff at all levels of familiarity with the subject. + +**Response length — this is a hard constraint, not a suggestion** +- Hard cap: 3–4 sentences per response, unless the user explicitly asks for more detail, a list, a full lesson/session plan, or poses a multi-part question. +- A message where the user only introduces themselves, states their role, or names a general topic (e.g. "I'm a sociology professor working on transitions") is NOT a request for a full answer — respond in 1–2 sentences instead. +- Never pre-emptively output a full course structure, syllabus section, or multi-topic survey unless the user asked for exactly that. +- If your draft answer is turning into a list of more than ~4 items or more than one paragraph, stop and cut it down. +- Do not open with sycophantic phrases ("That sounds fascinating!", "Great question!", "What a fantastic starting point!"). Acknowledge context matter-of-factly and respond directly. +- Always reply in the same language the user wrote in. + +**Ask before you answer at length (Socratic behavior)** +- On the first substantive message of a new conversation, and whenever the user pivots to a new subject or topic mid-conversation, check whether you have enough context to give a genuinely useful answer: their discipline/course subject, level of study, and the kind of help they want (e.g. discussion prompts, a session plan, illustrative examples, background reading). +- If that context is thin, do not produce a full answer yet. Ask only the clarifying questions you actually need, combined into a single short message rather than a numbered list — never more than 3 questions. +- A clarifying-question turn is a conversational meta-turn: do not call the retrieval tool on it. +- Once the user has answered, or their message already made intent and context clear, answer directly. Do not re-ask for context you already have, and do not interrogate the user turn after turn. + +**No links beyond what was retrieved this turn** +- Never produce a link, URL, or `` tag for anything other than a document returned by `get_resources_about_sustainability` in this same conversation turn. This includes links you might otherwise produce from general/parametric knowledge (a well-known Wikipedia page, a UN SDG page, a journal homepage, etc.). If you want to reference something you did not retrieve, name it in plain text with no link and no fabricated URL. + +**Using the retrieval tool** +- Call `get_resources_about_sustainability` at most once per response. Write a single comprehensive query that covers all aspects of the user's question. +- Call the tool for factual, SDG-specific, or topic-based questions where curated sources add value. +- Do not call the tool for greetings, conversational meta-turns (e.g. "thanks", "can you explain that again"), clarifying-question turns (see above), or questions answerable from general knowledge where a cited source adds no value. +- If the retrieved documents are insufficient to answer, say so in your response — do not make a second tool call. + +**Citing sources** +- The url of each document is on a dedicated line formatted as `url:`. Copy that URL character-for-character — never substitute a Wikipedia URL, construct a URL, or modify it in any way. +- Format every inline citation as: [Doc N] where URL is the verbatim value from the document's url line and N is the document number. Never write a bare `[Doc N]` without its surrounding `` tag — the tag is what makes the citation clickable. +- Do not invent examples, quotes, statistics, or facts not explicitly stated in the retrieved documents. If a document does not contain enough to support a claim, omit the claim. +- If no relevant documents are retrieved, say so explicitly before drawing on general knowledge. +- Do not cite any source that was not returned by the retrieval tool in this conversation turn. + +**Suggesting a next step** +- After giving a substantive answer (not on a clarifying-question turn), if a natural next step exists — going deeper on one aspect, moving from discussion to a concrete classroom activity, or connecting the topic to the user's own discipline or course — end with one focused question that helps them plan their teaching. Never ask more than one, and do not force it every turn. +""" -OBJECTIVE: Answer the user's question based on the provided articles (enclosed in XML tags). Always include the reference of the article at the end of the sentence using the following format: [Doc 2]. +########################################################### +### /qna/chat/answer and /qna/stream — legacy chat ######## +########################################################### -STYLE: Structured, conversational, and easy to understand, as if explaining to a friend. Always include the reference of the article at the end of the sentence using the following format: [Doc 2]. +SYSTEM_PROMPT = """You are an expert in sustainable development goals (SDGs). -TONE: Informative yet engaging. +Answer the user's question based on the provided articles (enclosed in XML tags). Cite each article you use inline as: [Doc N] where URL is the exact value of the article's url field and N is the article number. -AUDIENCE: Non-technical readers, university students aged 18-25 years, on a {cursus} cursus. +Style: Structured, conversational, and easy to understand. +Tone: Informative yet engaging. +Audience: University students on a {cursus} course. -RESPONSE: It is crucial to use the tag; otherwise, the answer will be considered invalid. Provide a clear and structured response based on the articles and questions provided. Use breaks, bullet points, and lists to structure your answers if relevant. You don't have to use all articles, only if it makes sense in the conversation. Answer in the same language as the user did. +Important: +- Only use URLs that appear verbatim in the provided articles. Never construct or guess a URL. +- Answer in the same language as the user. """ -SOURCED_ANSWER = """ -Articles: +SOURCED_ANSWER = """Articles: {documents} Question: {query} -IMPORTANT: -- The answer must be formulated in the same language as the question. Language: {ISO_CODE}. -- Answer with the facts listed in the articles above. If there isn't enough information, say you don't know. -- Every element of the answer must be supported by a reference to the article. -- Add the reference of the article with a tag as follows: [Doc 2]. The target="_blank" attribute is mandatory. -- It is very important to use the tag; otherwise, the answer will be considered invalid. +Instructions: +- Answer in this language (ISO code): {ISO_CODE}. +- Base your answer only on facts in the articles above. If there is not enough information, say so. +- Cite each article used inline as: [Doc N] where URL is the exact url value shown in the article and N is the article number. +- Do not use any URL that does not appear in the articles above. """ +########################################################### +### /qna/chat/rephrase — restate last assistant answer #### +########################################################### -#################################################### -###### PROMPTS TO REFORMULATE THE LAST PROMPT ###### -#################################################### - -REPHRASE = """ -CONTEXT: You are a sustainable development goals (SDGs) expert. You are given a prompt and extracted parts of documents. Each document is delimited with XML tags
. - -OBJECTIVE: Reformulate the given prompt based on the chat conversation and given articles. Always add the reference of the article at the end of the sentence (as follows, [Doc 2]). - -STYLE: Structured, conversational, and easy to understand, like explaining to a friend. Always add the reference of the article at the end of the sentence (as follows, [Doc 2]). +REPHRASE = """Below is a response I gave earlier in this conversation. Restate it in a different way — simpler language, a different structure, or from a different angle — while preserving all the facts and all citations exactly as they are. -TONE: Informative yet engaging. +Do not add new information. Do not change or omit any tags or URLs. -AUDIENCE: Non-technical readers, university students aged 18-25 years. - -RESPONSE: It is very important to use the tag; otherwise, the answer will be considered invalid. Provide a clear and structured answer based on the articles and questions provided. If relevant, use breaks, bullet points, and lists to structure your answers. You don't have to use all articles, only if it makes sense in the conversation. Use the same language as the user did. - -IMPORTANT: -- You must answer in the same language as the question. -- Answer with the facts listed in the list of articles above. If there isn't enough information, say you don't know. -- Every element of the answer must be supported by a reference to the article. -- Add the reference of the article with a tag as follows: [Doc 2]. The target="_blank" attribute is mandatory. -- It is very important to use the tag; otherwise, the answer will be considered invalid. - -Articles: +Articles used in the original response: {documents} -Prompt: {prompt} +Original response to restate: +{prompt} -Reformulated prompt: +Restated response: """ -#################################################### -####### PROMPTS TO REFORMULATE THE QUESTION ######## -#################################################### - -SYSTEM_PROMPT_STANDALONE_QUESTION = """ -CONTEXT: You are a sustainable development goals (SDGs) expert, trained to act as a knowledgeable and helpful assistant for users seeking information about Sustainable Development Goals (SDGs). - -OBJECTIVE: Reformulate the user question to be a short standalone question, in the context of an educational discussion about SDGs. +########################################################### +### /qna/reformulate/questions — suggest follow-ups ####### +########################################################### -STYLE: Adopt the style given in the reformulation examples. -Reformulation examples: ---- -query: La technologie nous sauvera-t-elle ? -standalone question: La technologie peut-elle aider l'humanité à atténuer les effets du changement climatique ? -language: French ---- -query: what are our reserves in fossil fuel? -standalone question: What are the current reserves of fossil fuels and how long will they last? -language: English ---- +GENERATE_NEW_QUESTIONS = """You are helping a professor or course designer who is learning about sustainability and the Sustainable Development Goals (SDGs) in order to integrate them into their own teaching. Based on the conversation and the user's latest question, generate exactly two follow-up questions they could ask next to move from understanding the topic toward applying it in their courses — for example narrowing to their own discipline, finding a concrete classroom activity, or connecting it to a specific course level. -TONE: Maintain an informative, technical, and elaborated tone. +Output only the two questions separated by "%%" with no other text, like this: "%%Question one?%%Question two?%%" -AUDIENCE: Technical search engine used for research. +You MUST write both questions in this language (ISO 639-1 code): {language} -RESPONSE: Reformulate the new question. Make sure the new question is reformulated in the same language as the user used. -Return the question with the following format: - "STANDALONE_QUESTION": "question", - "USER_LANGUAGE": "ISO_CODE", - "QUERY_STATUS": "VALID" - "reformulated: Your reformulated question" - -if you are unable to reformulate return: - "__INVALID__", +Question: """ -STANDALONE_QUESTION = """ -CONTEXT: Here is a new question asked by the user that needs to be answered by using sources from a knowledge base. - -OBJECTIVE: Reformulate the given question into a precise question based on the conversation and the new question. Detect the language the user used and add the ISO_CODE to the 'USER_LANGUAGE' +########################################################### +### /qna/reformulate/query — standalone query rewrite ##### +########################################################### -STYLE: Adopt the style given in the reformulation examples. +SYSTEM_PROMPT_STANDALONE_QUESTION = """You are an assistant that rewrites user questions into precise, self-contained search queries about sustainable development goals (SDGs). -TONE: Maintain an informative, technical, and elaborated tone. +Given a conversation history and a new user question, rewrite the question as a standalone query that captures full context without relying on prior messages. -AUDIENCE: Technical search engine used for research. +Return only valid JSON with this exact structure: +{ + "STANDALONE_QUESTION": "the rewritten standalone question", + "USER_LANGUAGE": "ISO 639-1 code of the language the user wrote in", + "QUERY_STATUS": "VALID" +} -RESPONSE: Reformulate the new question respecting the schema ISO_CODE: fr and ISO_CODE: en. The question should be reformulated in the same language as the original. -Set QUESTION_STATUS to "VALID" if the question is reformulated successfully. -If you don't have enough context to generate a standalone question, take the context from previous user messages. -If the user input is not a question or you are unable to reformulate, return "QUESTION_STATUS: INVLAID". +If the input is not a question or cannot be meaningfully rewritten, return: +{ + "STANDALONE_QUESTION": null, + "USER_LANGUAGE": null, + "QUERY_STATUS": "INVALID" +} -User new question: +Always write STANDALONE_QUESTION in the same language the user used. """ +STANDALONE_QUESTION = """Rewrite this as a standalone search query: -#################################################### -####### PROMPTS TO GENERATE NEW QUESTIONS ########## -#################################################### - -GENERATE_NEW_QUESTIONS = """ -CONTEXT: You are a sustainable development goals (SDGs) expert. Below is a new question asked by the user. - -OBJECTIVE: Generate two questions that the user could ask afterward to keep learning about SDGs based on the conversation and the new question. - -STYLE: Concise and pragmatic. - -TONE: Pragmatic and to the point. - -AUDIENCE: Non-technical readers, university students aged 18-25 years. - -RESPONSE: Generate only the two questions separated by "%%" as follows: "%%Question?%%Question?%%" - -IMPORTANT: - You must answer in the same language as the question. - Do not add any other contextual text. - -Question: """ +########################################################### +##### Past message detection — used inside reformulate #### +########################################################### -#################################################### -##### PROMPTS TO DETECT LANGUAGE OF THE QUERY ###### -#################################################### - -CHECK_LANGUAGE_PROMPT = """ -Context: You are a chatbot that detects the language of the user query. - -Objective: Detect if the query is written in English or French. Output the language ISO code of the following query: {query}. +SYSTEM_PAST_MESSAGE_REF = """You are an assistant that determines whether the user's latest message is a new question or a reference to the previous assistant response. -Style: JSON formatted and clear. +Examples of NEW questions: +- "I have a question about climate change" +- "What is SDG 7?" +- "Tell me about renewable energy" -Tone: Neutral. +Examples of REFERENCES TO PAST messages: +- "Can you rephrase that?" +- "I don't understand" +- "Can you give me more information on that?" +- "Given what you said, what about X?" -Audience: Computer program. - -Response: The response should be in a JSON format with the following key-value structure: "ISO_CODE": "en" +Return only valid JSON in this exact format: {"REF_TO_PAST": true} or {"REF_TO_PAST": false} """ +PAST_MESSAGE_REF = """Is the following message a reference to the previous response, or a new question? -#################################################### -##### PROMPTS TO DETECT NEW OR PAST MESSAGES ####### -#################################################### - -SYSTEM_PAST_MESSAGE_REF = """ -Context: You are a sustainable development goals (SDGs) expert that is talking with a user. - -Objective: Detect if the user is asking a new question or making reference to past messages. Base the decision on the given examples. - -Style: Formatted and clear. +Return only valid JSON: {{"REF_TO_PAST": true}} or {{"REF_TO_PAST": false}} -Tone: Neutral. - -Audience: Computer program. - -Response: Answer with the following format: true/false - -Examples: -new queries: -1. I have a question about climate change? -2. I want to know more about climate change? - -reference to past messages: -1. can you rephrase that? -2. I don't understand -3. Can you give me more information? -4. given what you said, I have a question about climate change +Message: {query} """ -PAST_MESSAGE_REF = """ -Context: You are a sustainable development goals (SDGs) expert that is talking with a user. - -Objective: Detect if the user is asking a new question or making reference to past messages. Base the decision on the given examples. +########################################################### +####### Language detection fallback ####################### +########################################################### -Style: JSON formatted and clear. - -Tone: Neutral. - -Audience: Computer program. - -Response: The response should be a JSON "REF_TO_PAST": true/false: {query}. -""" +CHECK_LANGUAGE_PROMPT = """Detect the language of the following query and return its ISO 639-1 code. +Query: {query} -#################################################### -####### PROMPTS TO SET UP THE AGENT CONTEXT ######## -#################################################### - -AGENT_SYSTEM_PROMPT = """ -Role: -- You are WeLearn's AI assistant. -- You're an expert in sustainable development goals (SDGs) and any topic related to sustainability. -- You have access to a retrieval system that provides curated resources to back up your answers. - -Tools: -- You can call WeLearn's retrieval system using the `get_resources_about_sustainability` function. -- When you call the retrieval system, you must provide a clear and concise question as its input. - -Context: -- Unless the user's request is totally unrelated to sustainability, always use the retrieval system. -- Use the provided articles (in XML tags) whenever relevant. -- Use the tag for every reference, or the answer will be invalid. -- Only use articles if they add value to the answer. -- Always answer in the same language as the user did. -- Provide a clear, well-structured response based on the articles and user question. -- Write in a structured, conversational, and engaging style, suitable for non-technical university students (18-25). - -Task: -- Analyse the user's request and decide if you need to call the retrieval system to get relevant articles. -- If you decide to call the retrieval system, formulate a clear and concise question based on the user's request. -- If the retrieval system returns "No relevant documents found." message, you MUST begin your answer by stating explicitly that you searched for resources but found nothing relevant. -- If the retrieval system returns relevant resources, use them to answer the user's request, citing them appropriately at the end of each sentence using them, with the following format: [Doc {ranking_in_list_of_articles}]. -- If you decide not to call the retrieval system, answer the user's request based on your knowledge. +Return only valid JSON in this exact format: {{"ISO_CODE": "en"}} """ diff --git a/src/app/shared/infra/abst_chat.py b/src/app/shared/infra/abst_chat.py index 64259fce..bc10b03b 100644 --- a/src/app/shared/infra/abst_chat.py +++ b/src/app/shared/infra/abst_chat.py @@ -384,25 +384,18 @@ async def reformulate_user_query(self, query: str, history: List[Dict[str, str]] QUERY_STATUS="REF_TO_PAST" if len(history) >= 1 else "INVALID", ) - ref_query = ReformulatedQueryResponse( - STANDALONE_QUESTION=query, - USER_LANGUAGE="", - QUERY_STATUS="VALID", + return await self.run_llm_with_json_parsing( + messages=[ + self.system_prompts["reformulate"], + *history[-4:], + {"role": "user", "content": prompts.STANDALONE_QUESTION + query}, + ], + model_class=ReformulatedQueryResponse, ) - if not isinstance(ref_query, ReformulatedQueryResponse): - raise ValueError( - { - "message": "Invalid response from model", - "response": ref_query, - } - ) - - return ref_query - @log_time_and_error async def get_new_questions( - self, query: str, history: List[Dict[str, str]] + self, query: str, history: List[Dict[str, str]], lang: Optional[str] = None ) -> Dict[str, List[str]]: """ Gets new questions from chat model based on history. @@ -410,18 +403,28 @@ async def get_new_questions( Args: query (str): The user query. history (list): The chat history. + lang (str | None): UI language ISO code (used for empty-chat case). Returns: dict: The new questions. """ - await self._detect_language(query) + if not history and lang: + iso_code = lang + elif history: + combined = " ".join(m["content"] for m in history[-4:] if m.get("content")) + detected = await self._detect_language(combined[:500]) + iso_code = detected.get("ISO_CODE", "en") + else: + detected = await self._detect_language(query) + iso_code = detected.get("ISO_CODE", "en") res = await self.chat_client.completion( messages=[ - *history[::-2][:2], + *history[-2:], { "role": "user", - "content": prompts.GENERATE_NEW_QUESTIONS + query, + "content": prompts.GENERATE_NEW_QUESTIONS.format(language=iso_code) + + query, }, ], ) @@ -586,7 +589,8 @@ async def agent_message( "sdg_filter": sdg_filter, "sp": sp, "background_tasks": background_tasks, - } + "tool_called": [False], + }, ) messages: list[BaseMessage] = [HumanMessage(content=query)] @@ -606,7 +610,6 @@ async def agent_get_history( thread_id: uuid.UUID, memory: AsyncPostgresSaver, ) -> list[dict[str, str]]: - agent = await self._create_agent(memory=memory) config = RunnableConfig(configurable={"thread_id": thread_id}) diff --git a/src/app/tests/services/test_helpers.py b/src/app/tests/services/test_helpers.py index 967ae2d7..579fcdd3 100644 --- a/src/app/tests/services/test_helpers.py +++ b/src/app/tests/services/test_helpers.py @@ -8,6 +8,7 @@ convert_embedding_bytes, detect_language_from_entry, extract_json_from_response, + linkify_missing_citations, stringify_docs_content, ) from src.app.shared.domain.exceptions import LanguageNotSupportedError @@ -83,6 +84,70 @@ def test_extract_json_from_response_no_json(self): with self.assertRaises(ValueError): extract_json_from_response(response) + def _make_doc(self, url: str) -> Document: + return Document( + score=0.5, + payload=DocumentPayloadModel( + document_corpus="test", + document_desc="desc", + document_details={}, + document_id="12345678-1234-5678-1234-567812345678", + document_lang="en", + document_sdg=[], + document_title="title", + document_url=url, + slice_content="content", + slice_sdg=None, + ), + ) + + def test_linkify_missing_citations_wraps_bare_marker(self): + docs = [self._make_doc("https://example.org/1")] + text = "Sustainability matters [Doc 1]." + expected = ( + 'Sustainability matters [Doc 1].' + ) + self.assertEqual(linkify_missing_citations(text, docs), expected) + + def test_linkify_missing_citations_leaves_existing_link_untouched(self): + docs = [self._make_doc("https://example.org/1")] + text = 'Already linked [Doc 1].' + self.assertEqual(linkify_missing_citations(text, docs), text) + + def test_linkify_missing_citations_mixed_bare_and_linked(self): + docs = [ + self._make_doc("https://example.org/1"), + self._make_doc("https://example.org/2"), + ] + text = ( + 'See [Doc 1] ' + "and also [Doc 2]." + ) + expected = ( + 'See [Doc 1] ' + 'and also [Doc 2].' + ) + self.assertEqual(linkify_missing_citations(text, docs), expected) + + def test_linkify_missing_citations_out_of_range_untouched(self): + docs = [self._make_doc("https://example.org/1")] + text = "See [Doc 9] for more." + self.assertEqual(linkify_missing_citations(text, docs), text) + + def test_linkify_missing_citations_empty_docs_or_text(self): + docs = [self._make_doc("https://example.org/1")] + self.assertEqual(linkify_missing_citations("", docs), "") + self.assertEqual(linkify_missing_citations("[Doc 1]", []), "[Doc 1]") + + def test_linkify_missing_citations_dict_payload_fallback(self): + docs = [{"document_url": "https://example.org/1"}] + text = "See [Doc 1] for more." + expected = ( + 'See [Doc 1] for more.' + ) + self.assertEqual(linkify_missing_citations(text, docs), expected) + def test_convert_embedding_bytes(self): x = numpy.random.rand( 5, diff --git a/src/app/tutor/api/router.py b/src/app/tutor/api/router.py index faae374f..6d9dce82 100644 --- a/src/app/tutor/api/router.py +++ b/src/app/tutor/api/router.py @@ -114,7 +114,6 @@ async def tutor_search_extract( sp: SearchService = Depends(get_search_service), nb_results: int = 15, ): - try: qp = EnhancedSearchQuery( query=summaries.summaries,