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
}
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{
Messages: []Message{
{

View file

@ -24,14 +24,11 @@ type Message struct {
type ChatCompletionRequest struct {
Model string `json:"model"`
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 {
Sort string `json:"sort,omitempty"`
} `json:"provider,omitempty"`
ResponseFormat any `json:"response_format,omitempty"` // keep flexible
Plugins []map[string]string `json:"plugins,omitempty"`
}
type ChatCompletionResponse struct {
@ -46,7 +43,7 @@ type ChatCompletionResponse struct {
} `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{
Model: p.Model,
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}
body, err := json.Marshal(req)
if err != nil {

View file

@ -3,5 +3,5 @@ package completion
import "context"
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/*
var headshots embed.FS
var tmpl = template.Must(template.New("index").Parse(indexTemplate))
var notFoundTmpl = template.Must(template.New("404").Parse(notFoundTemplate))
// Used for testimonial slider
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{
"openrouter": openrouter.OpenRouterProvider{

View file

@ -93,7 +93,7 @@ func GenerateSiteData(rawservice string, cp completion.CompletionProvider) (site
prompt = strings.TrimSpace(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 {
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".
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 {
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.
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)
if err != nil {
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.
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 {
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.
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 {
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(`
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.
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 {
return nil, fmt.Errorf("generating Isaac bio: %w", err)
}
site.IsaacBio = template.HTML(bio)
site.Testimonials = []Testimonial{
{
Name: "Barack Obama",
Position: "Poolhall acquaintance",
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!",
prompt = norm(`
Generate three satirical testimonials from satisfied clients of SVS, a %v company.
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.
This may be heavily ironic, say Abe Lincoln giving a testimonial for SVS's web design services, or Helen Keller praising their audio engineering.
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.
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",
},
{
Name: "Ada Lovelace",
Position: "Pioneer of Computing",
Quote: "The elegance and functionality of the websites created by SVS Web Design are truly ahead of their time.",
"Position": map[string]any{
"type": "string",
"description": "Position or title of the person giving the testimonial",
},
"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()
return
}

View file

@ -22,6 +22,10 @@
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
@ -54,13 +58,14 @@
}
header h1 {
margin: 0 0 10px;
font-size: clamp(2.2rem, 3vw, 3.4rem);
margin: 0;
font-size: clamp(2.2rem, 5vw, 5rem);
letter-spacing: 0.5px;
}
header h2 {
margin: 0 0 10px;
font-size: clamp(2.2rem, 3vw, 3rem);
font-weight: 600;
}
@ -107,6 +112,12 @@
color: var(--muted);
}
.section-lead {
text-align: center;
margin: 0 auto;
max-width: 700px;
}
#what-we-do {
background: #eef2ff;
}
@ -179,23 +190,31 @@
.slides {
position: relative;
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 {
position: absolute;
inset: 0;
opacity: 0;
transform: translateX(20px);
transition: opacity 250ms ease, transform 250ms ease;
flex: 0 0 calc(100% / var(--count));
padding: 16px;
text-align: center;
padding: 10px 16px;
}
.slide.active {
opacity: 1;
transform: translateX(0);
position: relative;
}
.slide p {
@ -207,11 +226,31 @@
.controls {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
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;
height: 42px;
border-radius: 50%;
@ -224,16 +263,83 @@
box-shadow: var(--shadow);
}
.control-btn:hover {
.control-arrow:hover {
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 {
background: #0b1224;
color: #f1f5f9;
padding: 40px 0;
}
footer a {
color: #cbd5e1;
}
footer a:hover {
color: #fff;
}
footer .footer-inner {
display: flex;
flex-direction: column;
@ -274,7 +380,7 @@
<section id="about">
<div class="container">
<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="profile-card">
<img src="headshots/chandlerswift.jpg"
@ -303,18 +409,33 @@
<section id="testimonials">
<div class="container">
<h2>What Our Clients Say</h2>
<div class="testimonial-shell" data-slider>
<div class="slides">
{{- $count := len .Testimonials -}}
<div class="testimonial-shell">
<div class="slides" style="--count: {{$count}};">
<div class="slides-track">
{{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>
<strong>— {{$testimonial.Name}}, {{$testimonial.Position}}</strong>
</div>
{{end}}
</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">
<button class="control-btn" data-prev aria-label="Previous testimonial">&#8592;</button>
<button class="control-btn" data-next aria-label="Next testimonial">&#8594;</button>
{{range $index, $_ := .Testimonials}}
<label class="control-dot" for="t-{{$index}}" aria-label="Show testimonial {{$index}}"></label>
{{end}}
</div>
</div>
</div>
</div>
@ -325,43 +446,12 @@
<p>
<b>Swift, Villnow &amp; Swift Industries</b><br>
{{.Occupation}} Division<br>
<a href="tel:2182105180" style="color:#cbd5e1;">(218) 210-5180</a><br>
<a href="mailto:sales@svsindustries.org" style="color:#cbd5e1;">sales@svsindustries.org</a>
<a href="tel:2182105180">(218) 210-5180</a><br>
<a href="mailto:sales@svsindustries.org">sales@svsindustries.org</a>
</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>
</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>
</html>