Skip to content
GitLab
Projects
Groups
Snippets
/
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in
Toggle navigation
Menu
Open sidebar
math_tutor_dev
public_math_tutor
Commits
f5891cbd
Commit
f5891cbd
authored
Feb 06, 2026
by
Kantz
Browse files
cklickbare quellen im Chat
parent
c5ff5771
Changes
8
Show whitespace changes
Inline
Side-by-side
math-tutor/backend/app/deterministic_services/orchestrators/orchestrator_QA.py
View file @
f5891cbd
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
(
...
...
math-tutor/backend/app/deterministic_services/orchestrators/orchestrator_tutor.py
View file @
f5891cbd
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
(
...
...
math-tutor/backend/app/deterministic_services/referenz_decoder.py
0 → 100644
View file @
f5891cbd
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
math-tutor/frontend/src/components/Chat/ChatWindow.tsx
View file @
f5891cbd
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
}
...
...
math-tutor/frontend/src/components/Chat/MessageBubble.tsx
View file @
f5891cbd
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
>
);
...
...
math-tutor/frontend/src/components/Chat/MessageList.tsx
View file @
f5891cbd
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
>
...
...
math-tutor/frontend/src/components/Retrieval/DocPanel.tsx
View file @
f5891cbd
...
...
@@ -2,6 +2,7 @@ import DocCard from "./DocCard";
export
type
RetrievedDoc
=
{
uid
:
string
;
sourceKey
?:
string
;
doc_type
:
string
;
score
?:
number
;
metadata
:
{
...
...
math-tutor/frontend/src/pages/App.tsx
View file @
f5891cbd
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
?
(
...
...
Write
Preview
Supports
Markdown
0%
Try again
or
attach a new file
.
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment