blob: d4c69c4977dd2afc9da501b33aa02a5455b5a0a1 [file] [log] [blame]
// Copyright 2024 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
import (
"bytes"
"context"
"fmt"
"net/http"
"slices"
"strconv"
"strings"
"time"
"github.com/google/safehtml"
"github.com/google/safehtml/template"
"golang.org/x/oscar/internal/github"
"golang.org/x/oscar/internal/htmlutil"
"golang.org/x/oscar/internal/llmapp"
"golang.org/x/oscar/internal/overview"
"golang.org/x/oscar/internal/search"
)
// overviewPage holds the fields needed to display the results
// of a search.
type overviewPage struct {
CommonPage
Params overviewParams // the raw query params
Result *overviewResult
Error error // if non-nil, the error to display instead of the result
}
type overviewResult struct {
Raw *llmapp.Result // the raw result
Issue *github.Issue // the issue that was analyzed
Typed any // additional, type-specific results
Type string // the type of result
// a text description of the type of result (for display), finishing
// the sentence "AI-generated Overview of ".
Desc string
}
// overviewParams holds the raw HTML parameters.
type overviewParams struct {
Query string // the issue ID to lookup, or golang/go#12345 or github.com/golang/go/issues/12345 form
LastReadComment string // (for [updateOverviewType]: summarize all comments after this comment ID)
OverviewType string // the type of overview to generate
}
// the possible overview types
const (
issueOverviewType = "issue_overview"
relatedOverviewType = "related_overview"
updateOverviewType = "update_overview"
)
// validOverviewType reports whether the given type
// is a recognized overview type.
func validOverviewType(t string) bool {
return t == issueOverviewType || t == relatedOverviewType || t == updateOverviewType
}
func (g *Gaby) handleOverview(w http.ResponseWriter, r *http.Request) {
handlePage(w, g.populateOverviewPage(r), overviewPageTmpl)
}
// fixMarkdown fixes mistakes that we have observed the LLM make
// when generating markdown.
func fixMarkdown(text string) string {
// add newline after backticks followed by a space
return strings.ReplaceAll(text, "``` ", "```\n")
}
var overviewPageTmpl = newTemplate(overviewPageTmplFile, template.FuncMap{
"fmttime": fmtTimeString,
})
// fmtTimeString formats an [time.RFC3339]-encoded time string
// as a [time.DateOnly] time string.
func fmtTimeString(s string) string {
if s == "" {
return s
}
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return s
}
return t.Format(time.DateOnly)
}
// parseIssueNumber parses the issue number from the given issue ID string.
// The issue ID string can be in one of the following formats:
// - "12345" (by default, assume it is a golang/go project's issue)
// - "golang/go#12345"
// - "github.com/golang/go/issues/12345" or "https://github.com/golang/go/issues/12345"
// - "go.dev/issues/12345" or "https://go.dev/issues/12345"
func parseIssueNumber(issueID string) (project string, issue int64, _ error) {
issueID = strings.TrimSpace(issueID)
if issueID == "" {
return "", 0, nil
}
split := func(q string) (string, string) {
q = strings.TrimPrefix(q, "https://")
// recognize github.com/golang/go/issues/12345
if proj, ok := strings.CutPrefix(q, "github.com/"); ok {
i := strings.LastIndex(proj, "/issues/")
if i < 0 {
return "", q
}
return proj[:i], proj[i+len("/issues/"):]
}
// recognize "go.dev/issues/12345"
if num, ok := strings.CutPrefix(q, "go.dev/issues/"); ok {
return "golang/go", num
}
// recognize golang/go#12345
if proj, num, ok := strings.Cut(q, "#"); ok {
return proj, num
}
return "", q
}
proj, num := split(issueID)
issue, err := strconv.ParseInt(num, 10, 64)
if err != nil || issue <= 0 {
return "", 0, fmt.Errorf("invalid issue number %q", issueID)
}
return proj, issue, nil
}
// parseIssueComment parses the issue comment ID from the given commentID string.
// The issue ID string can be in one of the following formats:
// - "6789": returns 6789 (assumed to be issue comment 6789 for the issue
// in [overviewParams.Query] in the "golang/go" repo)
//
// TODO(tatianabradley): allow comments to be expressed as URLs, e.g.:
// - "golang/go#12345#issuecomment6789"
// - "github.com/golang/go/issues/12345#issuecomment6789" or "https://github.com/golang/go/issues/12345#issuecomment6789"
// - "go.dev/issues/12345#issuecomment6789" or "https://go.dev/issues/12345#issuecomment6789"
func parseIssueComment(commentID string) (int64, error) {
commentID = trim(commentID)
return strconv.ParseInt(commentID, 10, 64)
}
// populateOverviewPage returns the contents of the overview page.
func (g *Gaby) populateOverviewPage(r *http.Request) *overviewPage {
pm := overviewParams{
Query: r.FormValue(paramQuery),
OverviewType: r.FormValue(paramOverviewType),
LastReadComment: r.FormValue(paramLastRead),
}
p := &overviewPage{
Params: pm,
}
p.setCommonPage()
if trim(p.Params.Query) == "" {
return p
}
overview, err := g.newOverview(r.Context(), &p.Params)
if err != nil {
p.Error = err
return p
}
p.Result = overview
return p
}
func (p *overviewPage) setCommonPage() {
p.CommonPage = CommonPage{
ID: overviewID,
Description: "Generate overviews of golang/go issues and their comments, or summarize the relationship between a golang/go issue and its related documents.",
FeedbackURL: "https://github.com/golang/oscar/issues/61#issuecomment-new",
Styles: []safeURL{searchID.CSS()},
Form: Form{
Inputs: p.Params.inputs(),
SubmitText: "generate",
},
}
}
const (
paramOverviewType = "t"
paramLastRead = "last_read"
)
var (
safeLastRead = toSafeID(paramLastRead)
)
// inputs converts the params to HTML form inputs.
func (pm *overviewParams) inputs() []FormInput {
return []FormInput{
{
Label: "issue",
Type: "int or string",
Description: "the issue to summarize, as a number or URL (e.g. 1234, golang/go#1234, or https://github.com/golang/go/issues/1234)",
Name: safeQuery,
Required: true,
Typed: TextInput{
ID: safeQuery,
Value: pm.Query,
},
},
{
Label: "overview type",
Type: "radio choice",
Description: `"issue and comments" generates an overview of the issue and its comments; "related documents" searches for related documents and summarizes them; "comments after" generates a summary of the comments after the specified comment ID`,
Name: toSafeID(paramOverviewType),
Required: true,
Typed: RadioInput{
Choices: []RadioChoice{
{
Label: "issue overview",
ID: toSafeID(issueOverviewType),
Value: issueOverviewType,
Checked: pm.checkRadio(issueOverviewType),
},
{
Label: "related documents",
ID: toSafeID(relatedOverviewType),
Value: relatedOverviewType,
Checked: pm.checkRadio(relatedOverviewType),
},
{
Label: "comments after",
Input: &FormInput{
Name: safeLastRead,
Typed: TextInput{
ID: safeLastRead,
Value: pm.LastReadComment,
},
},
ID: toSafeID(updateOverviewType),
Value: updateOverviewType,
Checked: pm.checkRadio(updateOverviewType),
},
},
},
},
}
}
// checkRadio reports whether radio button with the given value
// should be checked.
func (f *overviewParams) checkRadio(value string) bool {
// If the overview type is not set, or is set to an invalid value,
// the default option (issue overview) should be checked.
if !validOverviewType(f.OverviewType) {
return value == issueOverviewType
}
// Otherwise, the button corresponding to the result
// type should be checked.
return value == f.OverviewType
}
// newOverview generates an newOverview of the issue based on the given parameters.
func (g *Gaby) newOverview(ctx context.Context, pm *overviewParams) (*overviewResult, error) {
proj, issue, err := parseIssueNumber(pm.Query)
if err != nil {
return nil, fmt.Errorf("invalid form value: %v", err)
}
if proj == "" && len(g.githubProjects) > 0 {
proj = g.githubProjects[0] // default to first project.
}
if !slices.Contains(g.githubProjects, proj) {
return nil, fmt.Errorf("invalid form value (unrecognized project): %q", pm.Query)
}
iss, err := github.LookupIssue(g.db, proj, issue)
if err != nil {
return nil, err
}
switch pm.OverviewType {
case "", issueOverviewType:
return g.issueOverview(ctx, iss)
case relatedOverviewType:
return g.relatedOverview(ctx, iss)
case updateOverviewType:
lastReadComment, err := parseIssueComment(pm.LastReadComment)
if err != nil {
return nil, err
}
return g.updateOverview(ctx, iss, lastReadComment)
default:
return nil, fmt.Errorf("unknown overview type %q", pm.OverviewType)
}
}
// issueOverview generates an overview of the issue and its comments.
func (g *Gaby) issueOverview(ctx context.Context, iss *github.Issue) (*overviewResult, error) {
overview, err := g.overview.ForIssue(ctx, iss)
if err != nil {
return nil, err
}
return &overviewResult{
Raw: overview.Overview,
Issue: iss,
Typed: overview,
Type: issueOverviewType,
Desc: fmt.Sprintf("issue %d and all %d comments", iss.Number, overview.TotalComments),
}, nil
}
// relatedOverview generates an overview of the issue and its related documents.
func (g *Gaby) relatedOverview(ctx context.Context, iss *github.Issue) (*overviewResult, error) {
analysis, err := search.Analyze(ctx, g.llmapp, g.vector, g.docs, iss.DocID())
if err != nil {
return nil, err
}
return &overviewResult{
Raw: &analysis.Result,
Issue: iss,
Typed: analysis,
Type: relatedOverviewType,
Desc: fmt.Sprintf("issue %d and %d related docs", iss.Number, len(analysis.Output.Related)),
}, nil
}
// updateOverview generates an overview of the issue and its comments, split
// into "old" and "new" groups by lastReadComment.
func (g *Gaby) updateOverview(ctx context.Context, iss *github.Issue, lastReadComment int64) (*overviewResult, error) {
overview, err := g.overview.ForIssueUpdate(ctx, iss, lastReadComment)
if err != nil {
return nil, err
}
return &overviewResult{
Raw: overview.Overview,
Issue: iss,
Typed: overview,
Type: updateOverviewType,
Desc: fmt.Sprintf("issue %d and its %d new comments after %d", iss.Number, overview.NewComments, lastReadComment),
}, nil
}
// Related returns the relative URL of the related-entity search
// for the issue. This is used in the overview page template.
func (r *overviewResult) Related() string {
return fmt.Sprintf("/search?q=%s", r.Issue.HTMLURL)
}
// TotalComments returns the total number of comments for the
// analyzed issue, or 0 if not known.
func (r *overviewResult) TotalComments() int {
if ir, ok := r.Typed.(*overview.IssueResult); ok {
return ir.TotalComments
}
return 0
}
// Display returns the overview result as safe HTML.
func (r *overviewResult) Display() safehtml.HTML {
switch r.Type {
case issueOverviewType, updateOverviewType:
md := r.Raw.Response
md = fixMarkdown(md)
return htmlutil.MarkdownToSafeHTML(md)
case relatedOverviewType:
return displayRelated(r.Typed.(*search.Analysis))
}
return safehtml.HTML{}
}
// Template for converting a [search.Analysis] to Markdown.
const relatedMD = `
## Original Post
{{.Output.Summary}}
## Related Documents
{{range .Output.Related}}
### {{.Title}} ([{{.URL}}]({{.URL}}))
* **Summary**: {{.Summary}}
* **Relationship**: {{.Relationship}}
* **Relevance**: {{.Relevance}}
* **Relevance reason**: {{.RelevanceReason}}
{{end}}
`
var relatedMDTmpl = template.Must(template.New("relatedMD").Parse(relatedMD))
// displayRelated returns the result of a related documents
// analysis as safe HTML.
func displayRelated(a *search.Analysis) safehtml.HTML {
// Convert to Markdown, then HTML.
// We could instead convert directly to HTML, but Markdown is easier
// to work with, and we will likely publish these summaries to GitHub,
// which uses Markdown.
var buf bytes.Buffer
if err := relatedMDTmpl.Execute(&buf, a); err != nil {
panic(err)
}
return htmlutil.MarkdownToSafeHTML(buf.String())
}