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 referenz_decoder
from app.LLM_services import qa_LLM
from typing import List
......@@ -70,6 +71,11 @@ def run_chat(messages: list[dict], draft: str | None = None) -> dict:
if not reply:
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)
tool_logging.write_tool_log(
......
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.LLM_services import hint_LLM, decision_LLM, math_intent_LLM, solver_LLM
from typing import List
......@@ -101,6 +102,11 @@ def run_chat(messages: list[dict], draft: str | None = None) -> dict:
if not reply:
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)
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 MessageList from "./MessageList";
import type { ChatMessage } from "./MessageList";
import type { RetrievedDoc } from "../Retrieval/DocPanel";
type ChatWindowProps = {
messages: ChatMessage[];
......@@ -8,6 +9,9 @@ type ChatWindowProps = {
onDraftChange: (value: string) => void;
onSend: () => void;
onToggleCanvas?: () => void;
onInspectDoc?: (doc: RetrievedDoc) => void;
docIndex?: Record<string, RetrievedDoc>;
docSlugIndex?: Record<string, RetrievedDoc>;
};
export default function ChatWindow({
......@@ -16,11 +20,19 @@ export default function ChatWindow({
onDraftChange,
onSend,
onToggleCanvas,
onInspectDoc,
docIndex,
docSlugIndex,
}: ChatWindowProps) {
return (
<div className="chat-window">
<div className="chat-title">Chat</div>
<MessageList messages={messages} />
<MessageList
messages={messages}
onInspectDoc={onInspectDoc}
docIndex={docIndex}
docSlugIndex={docSlugIndex}
/>
<MessageInput
value={draft}
onChange={onDraftChange}
......
import { useEffect, useRef } from "react";
import ReactMarkdown from "react-markdown";
import type { RetrievedDoc } from "../Retrieval/DocPanel";
type MessageBubbleProps = {
role: "user" | "assistant";
text: string;
onInspectDoc?: (doc: RetrievedDoc) => void;
docIndex?: Record<string, RetrievedDoc>;
docSlugIndex?: Record<string, RetrievedDoc>;
};
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);
useEffect(() => {
......@@ -40,7 +50,76 @@ export default function MessageBubble({ role, text }: MessageBubbleProps) {
<div className={`message-bubble ${role}`} ref={bubbleRef}>
<div className="message-role">{role}</div>
<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>
);
......
import { useEffect, useRef } from "react";
import MessageBubble from "./MessageBubble";
import type { RetrievedDoc } from "../Retrieval/DocPanel";
export type ChatMessage = {
id: string;
......@@ -9,9 +10,17 @@ export type ChatMessage = {
type MessageListProps = {
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);
useEffect(() => {
......@@ -27,6 +36,9 @@ export default function MessageList({ messages }: MessageListProps) {
key={message.id}
role={message.role}
text={message.text}
onInspectDoc={onInspectDoc}
docIndex={docIndex}
docSlugIndex={docSlugIndex}
/>
))}
</div>
......
......@@ -2,6 +2,7 @@ import DocCard from "./DocCard";
export type RetrievedDoc = {
uid: string;
sourceKey?: string;
doc_type: string;
score?: number;
metadata: {
......
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import "../styles/theme.css";
import ChatWindow from "../components/Chat/ChatWindow";
import CanvasDrawer from "../components/Canvas/CanvasDrawer";
......@@ -53,6 +53,17 @@ const sourceIdToUid = (source: ContextSource, index: number) => {
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 parts = [
source.source_id.chapter_title,
......@@ -64,6 +75,7 @@ const sourceIdToPath = (source: ContextSource) => {
const toRetrievedDoc = (source: ContextSource, index: number): RetrievedDoc => ({
uid: sourceIdToUid(source, index),
sourceKey: sourceIdToKey(source.source_id),
doc_type: source.source_id.doc_type,
score: source.score,
metadata: {
......@@ -97,6 +109,49 @@ export default function App() {
message: string;
} | 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 trimmed = draft.trim();
if (!trimmed) {
......@@ -455,6 +510,9 @@ export default function App() {
onDraftChange={setDraft}
onSend={handleSend}
onToggleCanvas={handleToggleCanvas}
onInspectDoc={handleInspectDoc}
docIndex={docIndexes.bySourceKey}
docSlugIndex={docIndexes.bySlug}
/>
{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