mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-09-10 22:25:39 +00:00
send links to the LLM as markdown so drafts keep real hyperlinks
Transcripts flattened links to "text ( url )", so the model copied that shape and drafts came back with the URL in brackets instead of a link. HTML to text for the LLM now writes "[text](url)", which Markdown2HTML turns back into an anchor.
This commit is contained in:
@@ -661,7 +661,7 @@ func (m *Manager) messageText(msg cmodels.Message) string {
|
||||
var trimmed string
|
||||
if msg.ContentType == cmodels.ContentTypeHTML {
|
||||
// Render full with the same link-keeping options as trimmed, else the prefix diffing below never matches.
|
||||
if t := stringutil.HTML2TextWithLinks(msg.Content); t != "" {
|
||||
if t := stringutil.HTML2TextMarkdownLinks(msg.Content); t != "" {
|
||||
full = t
|
||||
}
|
||||
trimmed = stringutil.HTML2TextNoQuotes(msg.Content)
|
||||
|
||||
@@ -568,7 +568,7 @@ func Transcript(msgs []Message, max int) string {
|
||||
}
|
||||
text := strings.TrimSpace(msg.TextContent)
|
||||
if msg.ContentType == ContentTypeHTML {
|
||||
if t := stringutil.HTML2TextWithLinks(msg.Content); t != "" {
|
||||
if t := stringutil.HTML2TextMarkdownLinks(msg.Content); t != "" {
|
||||
text = t
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestTranscript(t *testing.T) {
|
||||
}
|
||||
|
||||
got := Transcript(msgs, 50)
|
||||
want := "Customer: My payment on this page ( https://example.com/pay ) failed.\n" +
|
||||
want := "Customer: My payment on [this page](https://example.com/pay) failed.\n" +
|
||||
"Agent: Looking into it.\n" +
|
||||
"Customer: Any update?\n"
|
||||
if got != want {
|
||||
|
||||
@@ -25,14 +25,14 @@ var (
|
||||
func HTML2TextNoQuotes(htmlContent string) string {
|
||||
doc, err := html.Parse(strings.NewReader(htmlContent))
|
||||
if err != nil {
|
||||
return TrimPlainTextQuotes(HTML2TextWithLinks(htmlContent))
|
||||
return TrimPlainTextQuotes(HTML2TextMarkdownLinks(htmlContent))
|
||||
}
|
||||
pruneQuotedNodes(doc)
|
||||
var b strings.Builder
|
||||
if err := html.Render(&b, doc); err != nil {
|
||||
return TrimPlainTextQuotes(HTML2TextWithLinks(htmlContent))
|
||||
return TrimPlainTextQuotes(HTML2TextMarkdownLinks(htmlContent))
|
||||
}
|
||||
return TrimPlainTextQuotes(HTML2TextWithLinks(b.String()))
|
||||
return TrimPlainTextQuotes(HTML2TextMarkdownLinks(b.String()))
|
||||
}
|
||||
|
||||
// TrimPlainTextQuotes strips a trailing quoted-reply block (">" lines, "On ... wrote:" and "Original Message" markers) from plain text.
|
||||
|
||||
@@ -41,7 +41,7 @@ func TestHTML2TextNoQuotes(t *testing.T) {
|
||||
{
|
||||
name: "protonmail",
|
||||
html: `<div><div>Hello with quoted.</div><div class="protonmail_signature_block"><div class="protonmail_signature_block-proton">Sent with <a href="https://proton.me/mail/home">Proton Mail</a> secure email.</div></div><div class="protonmail_quote">On Wednesday, July 22nd, 2026 at 9:06 PM, Abhinav <user@example.com> wrote:<br><blockquote class="protonmail_quote" type="cite"><p>Hello!</p></blockquote><br></div></div>`,
|
||||
want: "Hello with quoted.\nSent with Proton Mail ( https://proton.me/mail/home ) secure email.",
|
||||
want: "Hello with quoted.\nSent with [Proton Mail](https://proton.me/mail/home) secure email.",
|
||||
},
|
||||
{
|
||||
name: "quote only returns empty",
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package stringutil
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/jaytaylor/html2text"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// HTML2TextMarkdownLinks converts HTML to text keeping links as markdown "[text](url)", the form a
|
||||
// model copies back into its reply and that Markdown2HTML turns into an anchor again.
|
||||
func HTML2TextMarkdownLinks(htmlContent string) string {
|
||||
if doc, err := html.Parse(strings.NewReader(htmlContent)); err == nil {
|
||||
inlineAnchorMarkdown(doc)
|
||||
var b strings.Builder
|
||||
if err := html.Render(&b, doc); err == nil {
|
||||
htmlContent = b.String()
|
||||
}
|
||||
}
|
||||
return htmlToText(htmlContent, html2text.Options{})
|
||||
}
|
||||
|
||||
func inlineAnchorMarkdown(n *html.Node) {
|
||||
var next *html.Node
|
||||
for child := n.FirstChild; child != nil; child = next {
|
||||
next = child.NextSibling
|
||||
if child.Type == html.ElementNode && child.Data == "a" {
|
||||
link := markdownLink(nodeText(child), strings.TrimSpace(attrValue(child, "href")))
|
||||
if link != "" {
|
||||
n.InsertBefore(&html.Node{Type: html.TextNode, Data: link}, child)
|
||||
n.RemoveChild(child)
|
||||
continue
|
||||
}
|
||||
}
|
||||
inlineAnchorMarkdown(child)
|
||||
}
|
||||
}
|
||||
|
||||
// markdownLink returns "" when the anchor is not worth rewriting, leaving it to the text conversion.
|
||||
func markdownLink(text, href string) string {
|
||||
if href == "" || strings.HasPrefix(href, "#") || strings.HasPrefix(href, "cid:") {
|
||||
return ""
|
||||
}
|
||||
text = strings.Join(strings.Fields(text), " ")
|
||||
if text == "" || text == href {
|
||||
return href
|
||||
}
|
||||
if strings.ContainsAny(href, " ()") {
|
||||
href = "<" + href + ">"
|
||||
}
|
||||
return "[" + strings.NewReplacer("[", `\[`, "]", `\]`).Replace(text) + "](" + href + ")"
|
||||
}
|
||||
|
||||
func nodeText(n *html.Node) string {
|
||||
var b strings.Builder
|
||||
var walk func(*html.Node)
|
||||
walk = func(node *html.Node) {
|
||||
if node.Type == html.TextNode {
|
||||
b.WriteString(node.Data)
|
||||
}
|
||||
for c := node.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(n)
|
||||
return b.String()
|
||||
}
|
||||
@@ -48,11 +48,6 @@ func HTML2Text(html string) string {
|
||||
return htmlToText(html, html2text.Options{TextOnly: true})
|
||||
}
|
||||
|
||||
// HTML2TextWithLinks converts HTML to text keeping link URLs as "text ( url )".
|
||||
func HTML2TextWithLinks(html string) string {
|
||||
return htmlToText(html, html2text.Options{})
|
||||
}
|
||||
|
||||
// Markdown2HTML converts markdown to HTML, falling back to the input on error.
|
||||
func Markdown2HTML(md string) string {
|
||||
var b strings.Builder
|
||||
|
||||
@@ -297,16 +297,16 @@ func TestSplitName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTML2TextWithLinks(t *testing.T) {
|
||||
func TestHTML2TextMarkdownLinks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
html string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "link with distinct text keeps url",
|
||||
name: "link with distinct text becomes a markdown link",
|
||||
html: `<p>See <a href="https://example.com/guide">the guide</a> for steps.</p>`,
|
||||
want: "See the guide ( https://example.com/guide ) for steps.",
|
||||
want: "See [the guide](https://example.com/guide) for steps.",
|
||||
},
|
||||
{
|
||||
name: "link text equal to url not duplicated",
|
||||
@@ -318,10 +318,30 @@ func TestHTML2TextWithLinks(t *testing.T) {
|
||||
html: `<p>No links here.</p>`,
|
||||
want: "No links here.",
|
||||
},
|
||||
{
|
||||
name: "nested markup inside the anchor flattens to link text",
|
||||
html: `<p><a href="https://example.com/x"><strong>Pay</strong> now</a></p>`,
|
||||
want: "[Pay now](https://example.com/x)",
|
||||
},
|
||||
{
|
||||
name: "brackets in link text are escaped",
|
||||
html: `<p><a href="https://example.com">Docs [beta]</a></p>`,
|
||||
want: `[Docs \[beta\]](https://example.com)`,
|
||||
},
|
||||
{
|
||||
name: "url with parentheses is wrapped in angle brackets",
|
||||
html: `<p><a href="https://example.com/a(b)">See</a></p>`,
|
||||
want: "[See](<https://example.com/a(b)>)",
|
||||
},
|
||||
{
|
||||
name: "anchor without href keeps its text",
|
||||
html: `<p><a name="top">Top</a> of page.</p>`,
|
||||
want: "Top of page.",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := HTML2TextWithLinks(tt.html); got != tt.want {
|
||||
if got := HTML2TextMarkdownLinks(tt.html); got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user