Commit f5891cbd authored by Kantz's avatar Kantz
Browse files

cklickbare quellen im Chat

parent c5ff5771
from app.deterministic_services import context_store, retrieval_service, tool_logging from app.deterministic_services import context_store, retrieval_service, tool_logging
from app.deterministic_services import referenz_decoder
from app.LLM_services import qa_LLM from app.LLM_services import qa_LLM
from typing import List from typing import List
...@@ -70,6 +71,11 @@ def run_chat(messages: list[dict], draft: str | None = None) -> dict: ...@@ -70,6 +71,11 @@ def run_chat(messages: list[dict], draft: str | None = None) -> dict:
if not reply: if not reply:
reply = "Dazu steht nichts im Material" reply = "Dazu steht nichts im Material"
else:
decoded, _ = referenz_decoder.decode_references(
reply, context_store.get_retrieval(sheet)
)
reply = decoded
context_store.save_sheet(sheet) context_store.save_sheet(sheet)
tool_logging.write_tool_log( tool_logging.write_tool_log(
......
from app.deterministic_services import context_store, retrieval_service, tool_logging from app.deterministic_services import context_store, retrieval_service, tool_logging
from app.deterministic_services import referenz_decoder
from app.tools import math_tool from app.tools import math_tool
from app.LLM_services import hint_LLM, decision_LLM, math_intent_LLM, solver_LLM from app.LLM_services import hint_LLM, decision_LLM, math_intent_LLM, solver_LLM
from typing import List from typing import List
...@@ -101,6 +102,11 @@ def run_chat(messages: list[dict], draft: str | None = None) -> dict: ...@@ -101,6 +102,11 @@ def run_chat(messages: list[dict], draft: str | None = None) -> dict:
if not reply: if not reply:
reply = "Dazu steht nichts im Material" reply = "Dazu steht nichts im Material"
else:
decoded, _ = referenz_decoder.decode_references(
reply, context_store.get_retrieval(sheet)
)
reply = decoded
context_store.save_sheet(sheet) context_store.save_sheet(sheet)
tool_logging.write_tool_log( tool_logging.write_tool_log(
......
from __future__ import annotations
from typing import Dict, Iterable, Tuple
import re
from urllib.parse import quote
from app.deterministic_services import Source, SourceID
_BRACKET_RE = re.compile(r"\[(?P<ref>[^\[\]]+)\]")
def _source_id_key(source_id: SourceID) -> str:
parts = [
source_id.chapter_title or "",
source_id.section_title or "",
source_id.subsection_title or "",
source_id.title or "",
source_id.doc_type or "",
]
return "|".join(parts)
def _normalize_ref_token(token: str) -> str:
t = token.strip()
if t.startswith("[") and t.endswith("]"):
t = t[1:-1]
t = t.replace("/", "|")
t = re.sub(r"\s+", "", t)
return t.casefold()
def _build_source_index(sources: Iterable[Source]) -> Dict[str, str]:
index: Dict[str, str] = {}
for source in sources:
key = _source_id_key(source.source_id)
normalized_key = _normalize_ref_token(key)
index.setdefault(normalized_key, key)
raw_string = source.source_id.to_string()
index.setdefault(_normalize_ref_token(raw_string), key)
parts = [
source.source_id.chapter_title,
source.source_id.section_title,
source.source_id.subsection_title,
source.source_id.title,
]
filtered = [p for p in parts if p and p.lower() != "none"]
if filtered:
minimal = "|".join(filtered + [source.source_id.doc_type])
index.setdefault(_normalize_ref_token(minimal), key)
if source.source_id.title:
compact = f"{source.source_id.title}|{source.source_id.doc_type}"
index.setdefault(_normalize_ref_token(compact), key)
return index
def decode_references(text: str, sources: Iterable[Source]) -> Tuple[str, int]:
if not text:
return text, 0
index = _build_source_index(sources)
if not index:
return text, 0
replacements = 0
def replace(match: re.Match) -> str:
nonlocal replacements
ref = match.group("ref")
end = match.end()
# Skip existing markdown links: [label](...)
if end < len(text) and text[end:end + 1] == "(":
return match.group(0)
normalized = _normalize_ref_token(ref)
key = index.get(normalized)
if not key:
return match.group(0)
replacements += 1
href = f"doc://{quote(key, safe='')}"
return f"[{ref}]({href})"
return _BRACKET_RE.sub(replace, text), replacements
import MessageInput from "./MessageInput"; import MessageInput from "./MessageInput";
import MessageList from "./MessageList"; import MessageList from "./MessageList";
import type { ChatMessage } from "./MessageList"; import type { ChatMessage } from "./MessageList";
import type { RetrievedDoc } from "../Retrieval/DocPanel";
type ChatWindowProps = { type ChatWindowProps = {
messages: ChatMessage[]; messages: ChatMessage[];
...@@ -8,6 +9,9 @@ type ChatWindowProps = { ...@@ -8,6 +9,9 @@ type ChatWindowProps = {
onDraftChange: (value: string) => void; onDraftChange: (value: string) => void;
onSend: () => void; onSend: () => void;
onToggleCanvas?: () => void; onToggleCanvas?: () => void;
onInspectDoc?: (doc: RetrievedDoc) => void;
docIndex?: Record<string, RetrievedDoc>;
docSlugIndex?: Record<string, RetrievedDoc>;
}; };
export default function ChatWindow({ export default function ChatWindow({
...@@ -16,11 +20,19 @@ export default function ChatWindow({ ...@@ -16,11 +20,19 @@ export default function ChatWindow({
onDraftChange, onDraftChange,
onSend, onSend,
onToggleCanvas, onToggleCanvas,
onInspectDoc,
docIndex,
docSlugIndex,
}: ChatWindowProps) { }: ChatWindowProps) {
return ( return (
<div className="chat-window"> <div className="chat-window">
<div className="chat-title">Chat</div> <div className="chat-title">Chat</div>
<MessageList messages={messages} /> <MessageList
messages={messages}
onInspectDoc={onInspectDoc}
docIndex={docIndex}
docSlugIndex={docSlugIndex}
/>
<MessageInput <MessageInput
value={draft} value={draft}
onChange={onDraftChange} onChange={onDraftChange}
......
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import type { RetrievedDoc } from "../Retrieval/DocPanel";
type MessageBubbleProps = { type MessageBubbleProps = {
role: "user" | "assistant"; role: "user" | "assistant";
text: string; text: string;
onInspectDoc?: (doc: RetrievedDoc) => void;
docIndex?: Record<string, RetrievedDoc>;
docSlugIndex?: Record<string, RetrievedDoc>;
}; };
declare global { declare global {
...@@ -14,7 +18,13 @@ declare global { ...@@ -14,7 +18,13 @@ declare global {
} }
} }
export default function MessageBubble({ role, text }: MessageBubbleProps) { export default function MessageBubble({
role,
text,
onInspectDoc,
docIndex,
docSlugIndex,
}: MessageBubbleProps) {
const bubbleRef = useRef<HTMLDivElement | null>(null); const bubbleRef = useRef<HTMLDivElement | null>(null);
useEffect(() => { useEffect(() => {
...@@ -40,7 +50,76 @@ export default function MessageBubble({ role, text }: MessageBubbleProps) { ...@@ -40,7 +50,76 @@ export default function MessageBubble({ role, text }: MessageBubbleProps) {
<div className={`message-bubble ${role}`} ref={bubbleRef}> <div className={`message-bubble ${role}`} ref={bubbleRef}>
<div className="message-role">{role}</div> <div className="message-role">{role}</div>
<div className="message-text"> <div className="message-text">
<ReactMarkdown>{text}</ReactMarkdown> <ReactMarkdown
components={{
a: ({ href, children, ...props }) => {
const target = href || "";
if (!target.startsWith("doc://")) {
const mdMatch = target.match(/([^/]+)\.md$/i);
if (!mdMatch) {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
{...props}
>
{children}
</a>
);
}
const file = mdMatch[1] || "";
const slugFromFile = file
.toLowerCase()
.replace(/ä/g, "ae")
.replace(/ö/g, "oe")
.replace(/ü/g, "ue")
.replace(/ß/g, "ss")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
const slug = slugFromFile.split("-").pop() || slugFromFile;
const doc = docSlugIndex ? docSlugIndex[slug] : undefined;
return (
<a
href={href}
onClick={(event) => {
if (!doc || !onInspectDoc) {
return;
}
event.preventDefault();
onInspectDoc(doc);
}}
{...props}
>
{children}
</a>
);
}
const key = decodeURIComponent(target.slice("doc://".length));
const doc = docIndex ? docIndex[key] : undefined;
return (
<a
href={href}
onClick={(event) => {
if (!doc || !onInspectDoc) {
return;
}
event.preventDefault();
onInspectDoc(doc);
}}
{...props}
>
{children}
</a>
);
},
}}
>
{text}
</ReactMarkdown>
</div> </div>
</div> </div>
); );
......
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import MessageBubble from "./MessageBubble"; import MessageBubble from "./MessageBubble";
import type { RetrievedDoc } from "../Retrieval/DocPanel";
export type ChatMessage = { export type ChatMessage = {
id: string; id: string;
...@@ -9,9 +10,17 @@ export type ChatMessage = { ...@@ -9,9 +10,17 @@ export type ChatMessage = {
type MessageListProps = { type MessageListProps = {
messages: ChatMessage[]; messages: ChatMessage[];
onInspectDoc?: (doc: RetrievedDoc) => void;
docIndex?: Record<string, RetrievedDoc>;
docSlugIndex?: Record<string, RetrievedDoc>;
}; };
export default function MessageList({ messages }: MessageListProps) { export default function MessageList({
messages,
onInspectDoc,
docIndex,
docSlugIndex,
}: MessageListProps) {
const listRef = useRef<HTMLDivElement | null>(null); const listRef = useRef<HTMLDivElement | null>(null);
useEffect(() => { useEffect(() => {
...@@ -27,6 +36,9 @@ export default function MessageList({ messages }: MessageListProps) { ...@@ -27,6 +36,9 @@ export default function MessageList({ messages }: MessageListProps) {
key={message.id} key={message.id}
role={message.role} role={message.role}
text={message.text} text={message.text}
onInspectDoc={onInspectDoc}
docIndex={docIndex}
docSlugIndex={docSlugIndex}
/> />
))} ))}
</div> </div>
......
...@@ -2,6 +2,7 @@ import DocCard from "./DocCard"; ...@@ -2,6 +2,7 @@ import DocCard from "./DocCard";
export type RetrievedDoc = { export type RetrievedDoc = {
uid: string; uid: string;
sourceKey?: string;
doc_type: string; doc_type: string;
score?: number; score?: number;
metadata: { metadata: {
......
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import "../styles/theme.css"; import "../styles/theme.css";
import ChatWindow from "../components/Chat/ChatWindow"; import ChatWindow from "../components/Chat/ChatWindow";
import CanvasDrawer from "../components/Canvas/CanvasDrawer"; import CanvasDrawer from "../components/Canvas/CanvasDrawer";
...@@ -53,6 +53,17 @@ const sourceIdToUid = (source: ContextSource, index: number) => { ...@@ -53,6 +53,17 @@ const sourceIdToUid = (source: ContextSource, index: number) => {
return parts.length ? parts.join("|") : `source_${index}`; return parts.length ? parts.join("|") : `source_${index}`;
}; };
const sourceIdToKey = (sourceId: ContextSource["source_id"]) => {
const parts = [
sourceId.chapter_title ?? "",
sourceId.section_title ?? "",
sourceId.subsection_title ?? "",
sourceId.title ?? "",
sourceId.doc_type ?? "",
];
return parts.join("|");
};
const sourceIdToPath = (source: ContextSource) => { const sourceIdToPath = (source: ContextSource) => {
const parts = [ const parts = [
source.source_id.chapter_title, source.source_id.chapter_title,
...@@ -64,6 +75,7 @@ const sourceIdToPath = (source: ContextSource) => { ...@@ -64,6 +75,7 @@ const sourceIdToPath = (source: ContextSource) => {
const toRetrievedDoc = (source: ContextSource, index: number): RetrievedDoc => ({ const toRetrievedDoc = (source: ContextSource, index: number): RetrievedDoc => ({
uid: sourceIdToUid(source, index), uid: sourceIdToUid(source, index),
sourceKey: sourceIdToKey(source.source_id),
doc_type: source.source_id.doc_type, doc_type: source.source_id.doc_type,
score: source.score, score: source.score,
metadata: { metadata: {
...@@ -97,6 +109,49 @@ export default function App() { ...@@ -97,6 +109,49 @@ export default function App() {
message: string; message: string;
} | null>(null); } | null>(null);
const docIndexes = useMemo(() => {
const bySourceKey: Record<string, RetrievedDoc> = {};
const bySlug: Record<string, RetrievedDoc> = {};
const allDocs = [
...directChildren,
...indirectChildren,
...subsections,
...sections,
];
const slugify = (value: string) =>
value
.toLowerCase()
.replace(/ä/g, "ae")
.replace(/ö/g, "oe")
.replace(/ü/g, "ue")
.replace(/ß/g, "ss")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
allDocs.forEach((doc) => {
if (doc.sourceKey) {
bySourceKey[doc.sourceKey] = doc;
}
const candidates = [
doc.metadata.title,
doc.metadata.subsection_title,
doc.metadata.section_title,
doc.metadata.path,
]
.filter(Boolean)
.map((item) => String(item));
candidates.forEach((candidate) => {
const slug = slugify(candidate);
if (slug && !bySlug[slug]) {
bySlug[slug] = doc;
}
});
});
return { bySourceKey, bySlug };
}, [directChildren, indirectChildren, subsections, sections]);
const handleSend = async () => { const handleSend = async () => {
const trimmed = draft.trim(); const trimmed = draft.trim();
if (!trimmed) { if (!trimmed) {
...@@ -455,6 +510,9 @@ export default function App() { ...@@ -455,6 +510,9 @@ export default function App() {
onDraftChange={setDraft} onDraftChange={setDraft}
onSend={handleSend} onSend={handleSend}
onToggleCanvas={handleToggleCanvas} onToggleCanvas={handleToggleCanvas}
onInspectDoc={handleInspectDoc}
docIndex={docIndexes.bySourceKey}
docSlugIndex={docIndexes.bySlug}
/> />
{isCanvasVisible ? ( {isCanvasVisible ? (
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment