Add testimonials

This commit is contained in:
Chandler Swift 2025-12-28 19:42:39 -06:00
parent 06e943d478
commit 40223a12b5
7 changed files with 318 additions and 101 deletions

View file

@ -0,0 +1,69 @@
// this package provides a wrapper to test completion provider implementations.
package main
import (
"context"
"fmt"
"os"
"git.chandlerswift.com/chandlerswift/svs-services-server/completion/openrouter"
)
var provider = openrouter.OpenRouterProvider{
Token: os.Getenv("OPENROUTER_API_KEY"),
Model: "openai/gpt-oss-120b",
}
func main() {
prompt := "Generate a sample weather report."
// {
// name: 'weather',
// strict: true,
// schema: {
// type: 'object',
// properties: {
// location: {
// type: 'string',
// description: 'City or location name',
// },
// temperature: {
// type: 'number',
// description: 'Temperature in Celsius',
// },
// conditions: {
// type: 'string',
// description: 'Weather conditions description',
// },
// },
// required: ['location', 'temperature', 'conditions'],
// additionalProperties: false,
// },
// },
// }
schema := map[string]any{
"name": "weather",
"strict": true,
"schema": map[string]any{
"type": "object",
"properties": map[string]any{
"location": map[string]any{
"type": "string",
"description": "City or location name",
},
"temperature": map[string]any{
"type": "number",
"description": "Temperature in Celsius",
},
"conditions": map[string]any{
"type": "string",
"description": "Weather conditions description",
},
},
"required": []string{"location", "temperature", "conditions"},
"additionalProperties": false,
},
}
result, err := provider.Complete(context.TODO(), prompt, schema)
fmt.Println(err, result)
}

View file

@ -71,7 +71,8 @@ func (p LlamaServerProvider) Health() (err error) {
return nil return nil
} }
func (p LlamaServerProvider) Complete(ctx context.Context, prompt string) (response string, err error) { func (p LlamaServerProvider) Complete(ctx context.Context, prompt string, schema any) (response string, err error) {
// TODO: schema enforcement
req := Request{ req := Request{
Messages: []Message{ Messages: []Message{
{ {

View file

@ -24,14 +24,11 @@ type Message struct {
type ChatCompletionRequest struct { type ChatCompletionRequest struct {
Model string `json:"model"` Model string `json:"model"`
Messages []Message `json:"messages"` Messages []Message `json:"messages"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
Stop json.RawMessage `json:"stop,omitempty"` // string or []string; keep flexible
Provider struct { Provider struct {
Sort string `json:"sort,omitempty"` Sort string `json:"sort,omitempty"`
} `json:"provider,omitempty"` } `json:"provider,omitempty"`
ResponseFormat any `json:"response_format,omitempty"` // keep flexible
Plugins []map[string]string `json:"plugins,omitempty"`
} }
type ChatCompletionResponse struct { type ChatCompletionResponse struct {
@ -46,7 +43,7 @@ type ChatCompletionResponse struct {
} `json:"choices"` } `json:"choices"`
} }
func (p OpenRouterProvider) Complete(ctx context.Context, prompt string) (string, error) { func (p OpenRouterProvider) Complete(ctx context.Context, prompt string, schema any) (string, error) {
req := ChatCompletionRequest{ req := ChatCompletionRequest{
Model: p.Model, Model: p.Model,
Messages: []Message{ Messages: []Message{
@ -62,6 +59,18 @@ func (p OpenRouterProvider) Complete(ctx context.Context, prompt string) (string
}, },
} }
if schema != nil {
req.ResponseFormat = map[string]any{
"type": "json_schema",
"json_schema": schema,
}
req.Plugins = []map[string]string{
{
"id": "response-healing",
},
}
}
httpClient := http.Client{Timeout: 10 * time.Second} httpClient := http.Client{Timeout: 10 * time.Second}
body, err := json.Marshal(req) body, err := json.Marshal(req)
if err != nil { if err != nil {

View file

@ -3,5 +3,5 @@ package completion
import "context" import "context"
type CompletionProvider interface { type CompletionProvider interface {
Complete(ctx context.Context, prompt string) (string, error) Complete(ctx context.Context, prompt string, schema any) (string, error)
} }

20
main.go
View file

@ -26,8 +26,24 @@ var notFoundTemplate string
//go:embed headshots/* //go:embed headshots/*
var headshots embed.FS var headshots embed.FS
var tmpl = template.Must(template.New("index").Parse(indexTemplate)) // Used for testimonial slider
var notFoundTmpl = template.Must(template.New("404").Parse(notFoundTemplate)) var funcMap = template.FuncMap{
"add": func(a, b int) int {
return a + b
},
"sub": func(a, b int) int {
return a - b
},
"mod": func(a, b int) int {
if b == 0 {
return 0
}
return a % b
},
}
var tmpl = template.Must(template.New("index").Funcs(funcMap).Parse(indexTemplate))
var notFoundTmpl = template.Must(template.New("404").Funcs(funcMap).Parse(notFoundTemplate))
var completionProviders map[string]completion.CompletionProvider = map[string]completion.CompletionProvider{ var completionProviders map[string]completion.CompletionProvider = map[string]completion.CompletionProvider{
"openrouter": openrouter.OpenRouterProvider{ "openrouter": openrouter.OpenRouterProvider{

View file

@ -93,7 +93,7 @@ func GenerateSiteData(rawservice string, cp completion.CompletionProvider) (site
prompt = strings.TrimSpace(prompt) prompt = strings.TrimSpace(prompt)
prompt = whitespace.ReplaceAllString(prompt, " ") prompt = whitespace.ReplaceAllString(prompt, " ")
site.Occupation, err = cp.Complete(context.TODO(), fmt.Sprintf(prompt, rawservice)) site.Occupation, err = cp.Complete(context.TODO(), fmt.Sprintf(prompt, rawservice), nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("parsing service: %w", err) return nil, fmt.Errorf("parsing service: %w", err)
} }
@ -104,7 +104,7 @@ func GenerateSiteData(rawservice string, cp completion.CompletionProvider) (site
e.g. for a web design company, something like "Custom websites cooked to order". e.g. for a web design company, something like "Custom websites cooked to order".
Do not include quotes. Do not include quotes.
`) `)
site.Tagline, err = cp.Complete(context.TODO(), fmt.Sprintf(prompt, site.Occupation)) site.Tagline, err = cp.Complete(context.TODO(), fmt.Sprintf(prompt, site.Occupation), nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("generating tagline: %w", err) return nil, fmt.Errorf("generating tagline: %w", err)
} }
@ -129,7 +129,7 @@ func GenerateSiteData(rawservice string, cp completion.CompletionProvider) (site
Markup should be in HTML, including paragraph breaks. Markup should be in HTML, including paragraph breaks.
Do not wrap in any additional markup. Do not wrap in any additional markup.
`) `)
whatWeDo, err := cp.Complete(context.TODO(), fmt.Sprintf(prompt, site.Occupation)) whatWeDo, err := cp.Complete(context.TODO(), fmt.Sprintf(prompt, site.Occupation), nil)
site.WhatWeDo = template.HTML(whatWeDo) site.WhatWeDo = template.HTML(whatWeDo)
if err != nil { if err != nil {
return nil, fmt.Errorf("generating what we do: %w", err) return nil, fmt.Errorf("generating what we do: %w", err)
@ -150,7 +150,7 @@ func GenerateSiteData(rawservice string, cp completion.CompletionProvider) (site
Emphasize the relevance of all the above information to SVS's line of work. Emphasize the relevance of all the above information to SVS's line of work.
Do not shoehorn all the above information; only include each piece if relevant. Do not shoehorn all the above information; only include each piece if relevant.
`) `)
bio, err := cp.Complete(context.TODO(), fmt.Sprintf(prompt, site.Occupation)) bio, err := cp.Complete(context.TODO(), fmt.Sprintf(prompt, site.Occupation), nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("generating Chandler bio: %w", err) return nil, fmt.Errorf("generating Chandler bio: %w", err)
} }
@ -171,11 +171,11 @@ func GenerateSiteData(rawservice string, cp completion.CompletionProvider) (site
Emphasize the relevance of all the above information to SVS's line of work. Emphasize the relevance of all the above information to SVS's line of work.
Do not include all the above information; only mention each piece if relevant. Do not include all the above information; only mention each piece if relevant.
`) `)
bio, err = cp.Complete(context.TODO(), fmt.Sprintf(prompt, site.Occupation)) bio, err = cp.Complete(context.TODO(), fmt.Sprintf(prompt, site.Occupation), nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("generating Isaac bio: %w", err) return nil, fmt.Errorf("generating Eric bio: %w", err)
} }
site.EricBio = template.HTML(fmt.Sprintf("Eric is a seasoned professional in the %v industry, known for his expertise and dedication.", site.Occupation)) site.EricBio = template.HTML(bio)
prompt = norm(` prompt = norm(`
Generate a short, satirical/irreverent bio (2-4 sentences) for Isaac Swift, a co-founder of SVS, a %v company. Generate a short, satirical/irreverent bio (2-4 sentences) for Isaac Swift, a co-founder of SVS, a %v company.
@ -195,31 +195,63 @@ func GenerateSiteData(rawservice string, cp completion.CompletionProvider) (site
Emphasize the relevance of all the above information to SVS's line of work. Emphasize the relevance of all the above information to SVS's line of work.
Do not include all the above information; only mention each piece if relevant. Do not include all the above information; only mention each piece if relevant.
`) `)
bio, err = cp.Complete(context.TODO(), fmt.Sprintf(prompt, site.Occupation)) bio, err = cp.Complete(context.TODO(), fmt.Sprintf(prompt, site.Occupation), nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("generating Isaac bio: %w", err) return nil, fmt.Errorf("generating Isaac bio: %w", err)
} }
site.IsaacBio = template.HTML(bio) site.IsaacBio = template.HTML(bio)
prompt = norm(`
Generate three satirical testimonials from satisfied clients of SVS, a %v company.
site.Testimonials = []Testimonial{ Testimonials should be from well-known figures, possibly historical (say, a former US President).
{ When possible, the person quoted should have some tenuous connection to the services provided by SVS.
Name: "Barack Obama", This may be heavily ironic, say Abe Lincoln giving a testimonial for SVS's web design services, or Helen Keller praising their audio engineering.
Position: "Poolhall acquaintance", Other possible sources include George Washington, Thomas Edison, Nikola Tesla, Marie Curie, Albert Einstein, Winston Churchill, Franklin D. Roosevelt, John F. Kennedy, Ronald Reagan, Margaret Thatcher, and Martin Luther King Jr.
Quote: "After the fiasco of the ACA Website, I know how important it is to get a website right on the first try, and boy do these gentlemen deliver!", Each testimonial should be 1-2 sentences long, and include an author and their position.
For example, if SVS did web design, a testimonial might be from "Barack Obama", a "Poolhall acquaintance":
"After the fiasco of the ACA Website, I know how important it is to get a website right on the first try,
and boy do these gentlemen deliver!"
Do not include quotes around the testimonial text.
Return the testimonials in JSON format, as an array of objects with "name", "position", and "quote" properties.
`)
schema := map[string]any{
"name": "testimonials",
"strict": true,
"schema": map[string]any{
"type": "array",
"minItems": 3,
"maxItems": 3,
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"Name": map[string]any{
"type": "string",
"description": "Name of the person giving the testimonial",
}, },
{ "Position": map[string]any{
Name: "Ada Lovelace", "type": "string",
Position: "Pioneer of Computing", "description": "Position or title of the person giving the testimonial",
Quote: "The elegance and functionality of the websites created by SVS Web Design are truly ahead of their time.", },
"Quote": map[string]any{
"type": "string",
"description": "Testimonial text",
},
},
"required": []string{"Name", "Position", "Quote"},
"additionalProperties": false,
}, },
{
Name: "Steve Jobs",
Position: "Visionary Entrepreneur",
Quote: "Design is not just what it looks like and feels like. Design is how it works. SVS Web Design understands this principle deeply.",
}, },
} }
raw_testimonials, err := cp.Complete(context.TODO(), fmt.Sprintf(prompt, site.Occupation), schema)
if err != nil {
return nil, fmt.Errorf("generating testimonials: %w", err)
}
fmt.Println("Raw testimonials: %s\n", raw_testimonials)
err = json.Unmarshal([]byte(raw_testimonials), &site.Testimonials)
if err != nil {
return nil, fmt.Errorf("unmarshaling testimonials: %w", err)
}
site.CurrentYear = time.Now().Year() site.CurrentYear = time.Now().Year()
return return
} }

View file

@ -22,6 +22,10 @@
box-sizing: border-box; box-sizing: border-box;
} }
html {
scroll-behavior: smooth;
}
body { body {
margin: 0; margin: 0;
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif; font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
@ -54,13 +58,14 @@
} }
header h1 { header h1 {
margin: 0 0 10px; margin: 0;
font-size: clamp(2.2rem, 3vw, 3.4rem); font-size: clamp(2.2rem, 5vw, 5rem);
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
header h2 { header h2 {
margin: 0 0 10px; margin: 0 0 10px;
font-size: clamp(2.2rem, 3vw, 3rem);
font-weight: 600; font-weight: 600;
} }
@ -107,6 +112,12 @@
color: var(--muted); color: var(--muted);
} }
.section-lead {
text-align: center;
margin: 0 auto;
max-width: 700px;
}
#what-we-do { #what-we-do {
background: #eef2ff; background: #eef2ff;
} }
@ -179,23 +190,31 @@
.slides { .slides {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
min-height: 180px; min-height: 200px;
}
@property --auto {
syntax: '<number>';
inherits: false;
initial-value: 0;
}
.slides-track {
display: flex;
width: calc(100% * var(--count));
transform: translateX(calc(-33.3333% * var(--current, 0)));
transition: transform 320ms ease;
animation: auto-pan calc(var(--count) * 6s) infinite linear;
}
.slide-toggle {
display: none;
} }
.slide { .slide {
position: absolute; flex: 0 0 calc(100% / var(--count));
inset: 0; padding: 16px;
opacity: 0;
transform: translateX(20px);
transition: opacity 250ms ease, transform 250ms ease;
text-align: center; text-align: center;
padding: 10px 16px;
}
.slide.active {
opacity: 1;
transform: translateX(0);
position: relative;
} }
.slide p { .slide p {
@ -207,11 +226,31 @@
.controls { .controls {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center;
gap: 12px; gap: 12px;
margin-top: 20px; margin-top: 20px;
} }
.control-btn { .control-dot {
width: 10px;
height: 10px;
border-radius: 50%;
border: 1px solid #c7cedc;
background: #fff;
cursor: pointer;
transition: transform 150ms ease, background 150ms ease, border-color 150ms ease;
}
.control-dot:hover {
transform: scale(1.1);
background: #eef2ff;
}
.slides:has(.slide-toggle:checked) .slides-track {
animation-play-state: paused;
}
.control-arrow {
width: 42px; width: 42px;
height: 42px; height: 42px;
border-radius: 50%; border-radius: 50%;
@ -224,16 +263,83 @@
box-shadow: var(--shadow); box-shadow: var(--shadow);
} }
.control-btn:hover { .control-arrow:hover {
background: #eef2ff; background: #eef2ff;
} }
.arrow-sets {
position: absolute;
inset: 0;
pointer-events: none;
}
.arrow-set {
display: none;
position: absolute;
inset: 0;
padding: 0 8px;
align-items: center;
justify-content: space-between;
pointer-events: none;
}
.arrow-set:first-of-type {
display: flex;
}
@keyframes auto-pan {
0% {
--auto: 0;
}
100% {
--auto: calc(var(--count) - 1);
}
}
/* Active state wiring via :has so dots/arrows track selection. */
.slides {
--current: var(--auto);
}
/* Default to first dot when nothing is picked. */
.controls .control-dot:first-of-type {
background: #eef2ff;
border-color: #c7cedc;
}
.arrow-set .control-arrow {
pointer-events: auto;
}
{{- range $index, $_ := .Testimonials }}
.slides:has(#t-{{$index}}:checked) {
--current: {{$index}};
}
.slides:has(#t-{{$index}}:checked) .controls .control-dot[for="t-{{$index}}"] {
background: var(--accent);
border-color: var(--accent);
}
.slides:has(#t-{{$index}}:checked) .arrow-set.arrows-{{$index}} {
display: flex;
}
{{- end }}
footer { footer {
background: #0b1224; background: #0b1224;
color: #f1f5f9; color: #f1f5f9;
padding: 40px 0; padding: 40px 0;
} }
footer a {
color: #cbd5e1;
}
footer a:hover {
color: #fff;
}
footer .footer-inner { footer .footer-inner {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -274,7 +380,7 @@
<section id="about"> <section id="about">
<div class="container"> <div class="container">
<h2>About Us</h2> <h2>About Us</h2>
<p class="muted" style="text-align:center; margin:0 auto; max-width:700px;">Three builders, one vision: ship reliable, human-centered software and services.</p> <p class="muted section-lead">Three builders, one vision: ship reliable, human-centered software and services.</p>
<div class="about-grid"> <div class="about-grid">
<div class="profile-card"> <div class="profile-card">
<img src="headshots/chandlerswift.jpg" <img src="headshots/chandlerswift.jpg"
@ -303,18 +409,33 @@
<section id="testimonials"> <section id="testimonials">
<div class="container"> <div class="container">
<h2>What Our Clients Say</h2> <h2>What Our Clients Say</h2>
<div class="testimonial-shell" data-slider> {{- $count := len .Testimonials -}}
<div class="slides"> <div class="testimonial-shell">
<div class="slides" style="--count: {{$count}};">
<div class="slides-track">
{{range $index, $testimonial := .Testimonials}} {{range $index, $testimonial := .Testimonials}}
<div class="slide {{if eq $index 0}}active{{end}}"> <input type="radio" name="testimonial" id="t-{{$index}}" class="slide-toggle" {{if eq $index 0}}checked{{end}}>
<div class="slide">
<p>"{{$testimonial.Quote}}"</p> <p>"{{$testimonial.Quote}}"</p>
<strong>— {{$testimonial.Name}}, {{$testimonial.Position}}</strong> <strong>— {{$testimonial.Name}}, {{$testimonial.Position}}</strong>
</div> </div>
{{end}} {{end}}
</div> </div>
<div class="arrow-sets">
{{range $index, $_ := .Testimonials}}
{{- $prev := mod (add $index (sub $count 1)) $count -}}
{{- $next := mod (add $index 1) $count -}}
<div class="arrow-set arrows-{{$index}}">
<label class="control-arrow" for="t-{{$prev}}" aria-label="Previous testimonial">&#8592;</label>
<label class="control-arrow" for="t-{{$next}}" aria-label="Next testimonial">&#8594;</label>
</div>
{{end}}
</div>
<div class="controls"> <div class="controls">
<button class="control-btn" data-prev aria-label="Previous testimonial">&#8592;</button> {{range $index, $_ := .Testimonials}}
<button class="control-btn" data-next aria-label="Next testimonial">&#8594;</button> <label class="control-dot" for="t-{{$index}}" aria-label="Show testimonial {{$index}}"></label>
{{end}}
</div>
</div> </div>
</div> </div>
</div> </div>
@ -325,43 +446,12 @@
<p> <p>
<b>Swift, Villnow &amp; Swift Industries</b><br> <b>Swift, Villnow &amp; Swift Industries</b><br>
{{.Occupation}} Division<br> {{.Occupation}} Division<br>
<a href="tel:2182105180" style="color:#cbd5e1;">(218) 210-5180</a><br> <a href="tel:2182105180">(218) 210-5180</a><br>
<a href="mailto:sales@svsindustries.org" style="color:#cbd5e1;">sales@svsindustries.org</a> <a href="mailto:sales@svsindustries.org">sales@svsindustries.org</a>
</p> </p>
<p>&copy; {{.CurrentYear}} Swift, Villnow &amp; Swift Industries. All rights reserved.</p> <p>&copy; {{.CurrentYear}} Swift, Villnow &amp; Swift Industries<br>All rights reserved.</p>
</div> </div>
</footer> </footer>
<script>
(function () {
const slider = document.querySelector('[data-slider]');
if (!slider) return;
const slides = Array.from(slider.querySelectorAll('.slide'));
const prev = slider.querySelector('[data-prev]');
const next = slider.querySelector('[data-next]');
let index = slides.findIndex(s => s.classList.contains('active'));
function show(i) {
slides.forEach((s, idx) => {
s.classList.toggle('active', idx === i);
});
}
function go(delta) {
index = (index + delta + slides.length) % slides.length;
show(index);
}
prev?.addEventListener('click', () => go(-1));
next?.addEventListener('click', () => go(1));
let timer = setInterval(() => go(1), 6500);
slider.addEventListener('mouseenter', () => clearInterval(timer));
slider.addEventListener('mouseleave', () => {
timer = setInterval(() => go(1), 6500);
});
})();
</script>
</body> </body>
</html> </html>